Magic Link 인증
비밀번호 기반 로그인은 오래된 방식이지만 문제가 많다. 사용자는 비밀번호를 잊어버리고, 약한 비밀번호를 설정하고, 여러 서비스에 같은 비밀번호를 재사용한다. 서버 입장에서도 비밀번호를 안전하게 해싱하고 저장해야 하며, 유출 사고가 발생하면 전체 사용자에게 영향이 간다. Magic Link는 이 문제를 근본적으로 우회한다. 비밀번호 자체를 없애버리는 것이다.
Magic Link란
Magic Link는 이메일 기반의 비밀번호 없는(passwordless) 인증 방식이다. 사용자가 이메일 주소만 입력하면 서버가 고유한 일회용 토큰이 포함된 링크를 이메일로 보내고, 사용자가 그 링크를 클릭하면 로그인이 완료된다.
핵심 아이디어는 단순하다. 이메일 계정에 접근할 수 있다면, 그 사람이 본인이라고 간주하는 것이다. 이메일 자체가 이미 인증된 채널이라는 가정 위에 동작한다. 실제로 비밀번호 분실 시 "비밀번호 재설정 이메일"을 보내는 것도 결국 이메일을 신뢰하는 것이니, Magic Link는 이 과정에서 비밀번호라는 중간 단계를 아예 제거한 셈이다.
Slack, Notion, Medium 같은 서비스들이 Magic Link를 적극적으로 사용하고 있다.
인증 흐름
Magic Link 인증은 크게 두 단계로 나뉜다. 링크 요청과 토큰 검증이다.
1단계: 링크 요청
사용자 → [이메일 입력] → 서버 → [토큰 생성 + 이메일 발송] → 사용자 메일함
- 사용자가 로그인 폼에 이메일을 입력하고 제출한다.
- 서버는 해당 이메일로 등록된 계정이 있는지 확인한다.
- 있다면, 고유한 일회용 토큰을 생성하고 DB에 저장한다.
https://app.example.com/auth/sign-in/continue?token=abc123xyz형태의 링크를 이메일로 보낸다.- 사용자에게는 "이메일을 확인해주세요" 안내 화면을 보여준다.
서버 측에서 토큰을 생성할 때 고려해야 할 것들:
// 토큰 생성 시 고려사항
{
token: crypto.randomUUID(), // 예측 불가능한 랜덤 값
email: "user@example.com",
expiresAt: Date.now() + 10 * 60 * 1000, // 만료 시간 (보통 10~30분)
used: false // 사용 여부 (일회용)
}
- 토큰은 충분히 길고 랜덤해야 한다. 짧은 토큰은 브루트포스 공격에 취약하다. UUID v4나 최소 32바이트 이상의 crypto-safe 랜덤 문자열을 사용한다.
- 만료 시간이 있어야 한다. 영구적인 토큰은 유출 시 위험하다. 보통 10~30분으로 설정한다.
- 일회용이어야 한다. 한번 사용된 토큰은 즉시 무효화해야 한다.
2단계: 토큰 검증
사용자 → [링크 클릭] → 서버 → [토큰 검증 + 세션 생성] → 로그인 완료
- 사용자가 이메일의 링크를 클릭한다.
- 클라이언트가 URL에서 토큰을 추출해 서버로 보낸다.
- 서버는 토큰이 유효한지 검증한다 (존재 여부, 만료 여부, 사용 여부).
- 유효하다면 토큰을 사용 완료로 표시하고, 액세스 토큰(JWT 등)을 발급한다.
- 클라이언트는 받은 액세스 토큰을 저장하고 인증된 상태로 진입한다.
클라이언트 구현
클라이언트 측에서는 두 개의 화면이 필요하다. 이메일 확인 안내 화면과, 링크 클릭 후 토큰을 검증하는 화면이다.
이메일 확인 안내 화면
사용자가 이메일을 제출한 뒤 보게 되는 화면이다. "이메일을 확인해주세요"라는 안내와 함께 재전송 기능을 제공한다.
function EmailVerification() {
const email = useSearchParams().get("email") || "";
const [resendCooldown, setResendCooldown] = useState(20);
useEffect(() => {
if (resendCooldown > 0) {
const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [resendCooldown]);
const handleResend = async () => {
if (resendCooldown > 0) return;
await authApi.requestMagicLinkSignIn({ email });
setResendCooldown(60); // 재전송 후 쿨다운 증가
};
return (
<div>
<h1>Please check your email</h1>
<p>We've sent a magic link to {email}</p>
{resendCooldown > 0 ? (
<span>Resend in {resendCooldown} seconds</span>
) : (
<button onClick={handleResend}>Resend</button>
)}
</div>
);
}
재전송 쿨다운이 중요하다. 없으면 사용자가 버튼을 반복 클릭해서 이메일을 대량 발송할 수 있다. 처음에는 20초, 재전송 후에는 60초로 늘리는 방식이 일반적이다. 서버 측에서도 Rate Limiting을 별도로 걸어야 한다.
토큰 검증 화면
사용자가 이메일의 링크를 클릭하면 도달하는 화면이다. URL의 토큰을 자동으로 추출해서 서버에 검증을 요청한다.
function MagicLinkVerify() {
const router = useRouter();
const token = useSearchParams().get("token") || "";
const verifyMutation = useVerifyMagicLinkToken();
useEffect(() => {
if (!token) {
router.replace("/sign-in");
return;
}
const timer = setTimeout(() => {
verifyMutation.mutate(token, {
onSuccess: () => router.push("/"),
onError: (error) => {
// 에러 처리 후 로그인 페이지로 리다이렉트
setTimeout(() => router.push("/sign-in"), 2000);
},
});
}, 1000);
return () => clearTimeout(timer);
}, [token]);
return <p>Logging you in...</p>;
}
여기서 setTimeout 1초 딜레이가 있는데, 이는 페이지 로드 직후 바로 API를 호출하면 React의 Strict Mode에서 이중 호출이 발생할 수 있기 때문이다. 실질적으로는 UX적으로도 "인증 중..." 상태를 잠깐 보여주는 효과가 있다.
API 설계
Magic Link API는 최소 두 개의 엔드포인트가 필요하다.
링크 요청 API
// POST /auth/magic-link
const requestMagicLinkSignIn = async (request: { email: string }): Promise<void> => {
const response = await fetch(`${BASE_URL}/auth/magic-link`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new AuthApiError(error.message, response.status, error.code);
}
};
이 API는 의도적으로 성공/실패 여부를 구분하지 않는 것이 좋다. 이메일이 등록되지 않은 경우에도 "이메일을 확인해주세요"라고 응답해야 한다. 왜냐하면 "해당 이메일은 등록되어 있지 않습니다"라고 알려주면 공격자가 이메일 존재 여부를 확인할 수 있기 때문이다(User Enumeration 공격).
토큰 검증 API
// POST /auth/magic-link/verify/:token
const verifyMagicLinkToken = async (token: string): Promise<{ accessToken: string; tfa?: boolean }> => {
const response = await fetch(`${BASE_URL}/auth/magic-link/verify/${token}`, {
method: "POST",
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new AuthApiError(error.message, response.status, error.code);
}
return response.json();
};
토큰을 URL path에 넣는 방식과 request body에 넣는 방식이 있다. Path 방식이 RESTful하고, 검증 결과에 따라 적절한 HTTP 상태 코드(200, 400, 410 Gone)를 반환하기 편하다.
응답에 tfa 필드가 있는 것은 Magic Link 이후 2단계 인증(2FA)을 추가로 요구할 수 있기 때문이다. Magic Link만으로는 이메일 계정이 탈취된 경우에 무방비하므로, 보안이 중요한 서비스에서는 Magic Link + TOTP 조합을 사용하기도 한다.
비밀번호 방식과 비교
| 항목 | 비밀번호 | Magic Link |
|---|---|---|
| 사용자 부담 | 비밀번호 기억 필요 | 없음 |
| 피싱 위험 | 높음 (가짜 로그인 페이지) | 낮음 (비밀번호가 없으니 탈취 불가) |
| 브루트포스 | 약한 비밀번호는 취약 | 토큰이 랜덤이라 사실상 불가 |
| 서버 저장 | 해시된 비밀번호 영구 저장 | 일회용 토큰 임시 저장 |
| 로그인 속도 | 빠름 (비밀번호 입력) | 느림 (이메일 확인 필요) |
| 이메일 의존성 | 없음 | 이메일 접근 필수 |
| 오프라인 사용 | 가능 | 불가능 |
Magic Link의 가장 큰 단점은 이메일에 대한 의존성이다. 이메일이 느리게 도착하거나, 스팸 폴더로 빠지거나, 이메일 서비스 자체에 장애가 있으면 로그인이 불가능하다. 그래서 대부분의 서비스는 Magic Link를 유일한 인증 수단으로 사용하지 않고, 소셜 로그인(Google, Apple)이나 비밀번호와 병행한다.
보안 고려사항
Magic Link는 비밀번호 문제를 해결하지만, 자체적인 보안 리스크가 있다.
토큰 보안
- 토큰 길이: 최소 128비트(16바이트) 이상의 엔트로피가 필요하다. UUID v4는 122비트 엔트로피로 충분하다.
- 만료 시간: 짧을수록 안전하다. 10분이 일반적. 너무 짧으면 이메일 지연으로 인한 실패가 늘어나고, 너무 길면 유출 시 악용 가능 시간이 늘어난다.
- 일회용: 검증 성공 즉시 해당 토큰을 삭제하거나
used: true로 마킹한다.
Rate Limiting
같은 이메일로 1분에 최대 1건
같은 IP에서 10분에 최대 5건
전체 시스템에서 분당 최대 100건
Rate Limiting이 없으면 공격자가 특정 이메일로 대량의 Magic Link를 발송해서 이메일 폭탄 공격을 할 수 있다. 서버의 이메일 발송 비용도 올라간다.
링크 노출 방지
- 이메일 본문에서 링크가 미리보기로 펼쳐지지 않도록 주의한다.
- HTTP Referer 헤더를 통해 토큰이 외부로 유출되지 않도록, 랜딩 페이지에서
Referrer-Policy: no-referrer를 설정한다. - 검증 페이지 URL이 브라우저 히스토리에 남으므로, 검증 완료 후
router.replace()로 히스토리를 교체한다.
OTP와의 차이
Magic Link와 비슷한 개념으로 이메일 OTP(One-Time Password)가 있다. 이메일로 6자리 코드를 보내고 사용자가 직접 입력하는 방식이다.
Magic Link: 이메일 → 링크 클릭 → 자동 검증
Email OTP: 이메일 → 코드 확인 → 수동 입력 → 검증
Magic Link가 UX 면에서 우월하다. 클릭 한번이면 끝나기 때문이다. 하지만 OTP는 다른 기기에서 코드를 확인하고 입력할 수 있다는 장점이 있다. Magic Link는 이메일을 확인하는 기기와 로그인하는 기기가 같아야 하는 제약이 있다(링크 클릭이 다른 브라우저에서 열리면 세션이 다르기 때문).
구현 시 흔한 실수
-
토큰을 GET 파라미터로 전달: URL이 서버 로그, 브라우저 히스토리, Referer 헤더에 남는다. path parameter나 fragment(
#token=...)를 사용하거나, 랜딩 페이지에서 즉시 POST로 토큰을 전송하는 것이 낫다. -
이메일 존재 여부 노출: "해당 이메일은 등록되어 있지 않습니다" 같은 에러 메시지는 User Enumeration 취약점이다. 항상 동일한 응답을 반환해야 한다.
-
토큰 재사용 허용: 한번 사용된 토큰을 무효화하지 않으면, 이메일에 접근 가능한 다른 사람이 같은 링크로 로그인할 수 있다.
-
쿨다운 없는 재전송: 클라이언트에서만 쿨다운을 걸면 API를 직접 호출해서 우회할 수 있다. 서버 측 Rate Limiting이 반드시 필요하다.
정리
- 이메일 소유 확인만으로 인증을 완료하는 방식으로, 비밀번호 저장·해싱·유출 리스크를 근본적으로 제거한다
- 토큰은 128비트 이상 엔트로피 + 10~30분 만료 + 일회용이 필수이며, 서버 측 Rate Limiting 없이는 이메일 폭탄에 노출된다
- 이메일 지연·스팸 필터링·오프라인 환경에서 로그인이 불가능하므로, 단독 인증 수단보다는 소셜 로그인과 병행하는 것이 일반적이다