Supabase Auth로 이메일 로그인을 붙이려 하면 처음에는 꽤 단순해 보입니다. 이메일 주소를 받고 signInWithOtp()를 호출하면 끝날 것처럼 느껴집니다. 그런데 실제로 구현해보면 문제는 그 다음부터 시작됩니다. 메일은 보내졌는데 링크가 localhost로 가고, OTP를 기대했는데 magic link가 오고, 회원가입을 막고 싶었는데 사용자가 자동 생성되고, Next.js App Router에서 세션이 어디서 생기는지 흐름이 불분명해지는 식입니다.
이 글은 제목은 경험형이지만, 내용은 “제가 겪었다고 꾸며낸 사례”가 아니라 2026년 4월 8일 기준 Supabase 공식 문서와 Next.js용 SSR 가이드를 바탕으로, 실제 구현에서 자주 부딪히는 함정을 정리한 글입니다. 아래에서 “이건 공식 문서 사실”과 “이 사실들을 합쳐서 나온 실무적 해석”을 구분해서 설명하겠습니다.
먼저 공식 문서에서 확인되는 큰 흐름은 이렇습니다.
- 이메일 기반 passwordless 로그인은 Magic Link와 OTP 두 방식이 있다
signInWithOtp()는 이름과 달리 기본적으로 Magic Link를 보낸다redirectTo나emailRedirectTo가 있더라도, 대시보드의 redirect URL allow list와 이메일 템플릿이 같이 맞아야 한다- SSR에서는
@supabase/ssr를 기준으로 client/server 구성을 나누고, Next.js 쪽 프록시 흐름을 갖추는 편이 맞다
즉 이메일 로그인에서 자주 나는 오류는 “한 함수가 틀렸다”보다 “대시보드 설정, 이메일 템플릿, 앱 라우트, 세션 저장 방식이 서로 안 맞는다”에 가깝습니다.
Supabase Auth 이메일 로그인에서 자주 꼬이는 설정과 흐름을 정리합니다. `signInWithOtp`, redirect URL, 이메일 템플릿, Next.js SSR 클라이언트, 토큰 검증까지 공식 문서 기준으로 실제 함정을 설명합니다. 핵심 1 핵심 2 핵심 3Supabase Auth 이메일 로그인을 붙이며 겪은 실수와 해결법
먼저 결론: 이메일 로그인은 함수 하나보다 “전체 흐름”을 먼저 정해야 한다
signInWithOtp()를 호출했는데 OTP가 아니라 magic link가 오는 문제
로그인만 만들고 싶었는데 사용자가 자동 생성되는 문제
먼저 결론: 이메일 로그인은 함수 하나보다 “전체 흐름”을 먼저 정해야 한다
제가 먼저 정하는 질문은 아래 다섯 가지입니다.
- 나는 Magic Link를 쓸 건가, 6자리 OTP를 쓸 건가
- 신규 유저 자동 생성이 필요한가
- 로그인 후 최종 목적지는 어디인가
- 콜백에서 세션을 어디서 만들 건가
- Next.js는 CSR 중심인가, SSR과 쿠키 세션까지 쓸 건가
이 다섯 질문이 정리되지 않으면 코드는 돌아가도 경험이 금방 꼬입니다. 예를 들어 OTP를 원했는데 이메일 템플릿은 여전히 magic link 기준이고, 로그인 후 /dashboard로 보내고 싶은데 Supabase 대시보드의 Site URL은 localhost 그대로면 메일은 정상 발송돼도 사용자는 계속 이상한 곳으로 이동하게 됩니다.
1. signInWithOtp()를 호출했는데 OTP가 아니라 magic link가 오는 문제
이건 가장 흔한 착각입니다. Supabase의 passwordless 문서에 따르면 signInWithOtp()는 이름과 달리 기본적으로 Magic Link를 보냅니다. 문서도 “Though the method is labelled ‘OTP’, it sends a Magic Link by default”라고 명시합니다. 즉 함수 이름만 보고 “6자리 코드가 가겠지”라고 생각하면 첫 단계부터 어긋납니다.
기본 Magic Link 요청 코드는 이렇게 생깁니다.
src/app/login/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
export async function sendMagicLink(email: string) {
const supabase = await createClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: "https://example.com/auth/confirm",
shouldCreateUser: false,
},
});
if (error) throw error;
}
그런데 “나는 링크 클릭보다 코드 입력 UX가 낫다”고 판단했다면, 함수가 아니라 이메일 템플릿을 바꿔야 합니다. Supabase 문서는 이메일 OTP를 보내려면 이메일 템플릿에 {{ .Token }} 변수를 사용하라고 설명합니다. 반대로 magic link는 {{ .ConfirmationURL }} 또는 {{ .TokenHash }} 기반 링크를 사용합니다.
즉 실무적 해석은 이렇습니다.
- 함수 호출만 바꿔서는 OTP UX가 완성되지 않는다
- 대시보드의 Email Template까지 같이 바꿔야 진짜 OTP 흐름이 된다
2. 로그인만 만들고 싶었는데 사용자가 자동 생성되는 문제
Supabase 공식 문서에서 이 부분도 꽤 중요하게 적혀 있습니다. signInWithOtp()는 사용자가 아직 존재하지 않으면 기본적으로 자동 가입을 수행합니다. 이를 막으려면 shouldCreateUser: false를 명시해야 합니다.
이 기본값은 편리할 수 있지만, 운영 의도와 다를 때가 많습니다.
- 초대 기반 서비스인데 아무 이메일이나 넣어도 계정이 생김
- “로그인” 화면인데 사실상 회원가입 화면처럼 동작함
- 존재하지 않는 계정도 같은 UX로 흘러가서 운영자가 헷갈림
그래서 저는 의도가 명확하지 않을 때는 거의 항상 아래처럼 적습니다.
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
shouldCreateUser: false,
},
});
이건 공식 문서 그대로의 사실이고, 여기에 제 실무적 해석을 붙이면 이렇습니다. “로그인”과 “가입”을 따로 보이게 하고 싶은 서비스라면 기본값에 기대지 않는 편이 훨씬 낫습니다.
3. 메일 링크가 자꾸 localhost나 엉뚱한 도메인으로 가는 문제
Supabase Redirect URLs 문서는 이 부분을 매우 분명하게 설명합니다. Site URL은 redirectTo가 없을 때의 기본 이동 주소이고, email confirmations와 password resets에도 매우 중요합니다. 추가 redirect URL 목록 역시 allow list 역할을 합니다.
즉 아래 셋이 같이 맞아야 합니다.
- Dashboard의
Site URL - Dashboard의 추가 Redirect URLs
- 코드에서 넘기는
emailRedirectTo또는redirectTo
예를 들어 로컬 개발만 하다가 production 배포 후 Site URL을 바꾸지 않으면, 메일은 계속 http://localhost:3000을 기준으로 링크를 만들 수 있습니다. 이건 코드 문제가 아니라 대시보드 설정 문제입니다.
제가 먼저 점검하는 순서는 아래와 같습니다.
Site URL이 실제 운영 도메인인지http://localhost:3000/**같은 개발용 URL이 추가 allow list에 있는지- production callback 경로가 exact path 기준으로 포함돼 있는지
- 코드의
emailRedirectTo가 실제 allow list와 일치하는지
예를 들면 이런 식입니다.
const origin =
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
shouldCreateUser: false,
emailRedirectTo: `${origin}/auth/confirm?next=/dashboard`,
},
});
중요한 건 “코드에서 넘긴 URL”만 맞다고 끝이 아니라, Supabase 대시보드의 allow list도 같은 방향을 가리켜야 한다는 점입니다.
4. redirectTo를 넘겼는데 이메일 템플릿이 여전히 {{ .SiteURL }}를 쓰는 문제
이건 redirect URL 문제의 진짜 함정입니다. Supabase Redirect URLs 문서는 redirectTo를 사용할 때 이메일 템플릿 안의 {{ .SiteURL }}를 {{ .RedirectTo }}로 바꿔야 할 수 있다고 명시합니다. 그리고 Email Templates 문서도 {{ .RedirectTo }}는 signUp, signInWithOtp, signInWithOAuth, resetPasswordForEmail 등에서 전달한 redirect URL을 담는다고 설명합니다.
즉 코드에서 이렇게 써도:
await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: "https://example.com/auth/confirm",
},
});
이메일 템플릿이 여전히 이렇게 되어 있으면:
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
Log in
</a>
당신이 코드에서 넘긴 경로보다 프로젝트 기본 Site URL 기준 링크가 우선 눈에 띄게 동작할 수 있습니다.
그래서 저는 커스텀 콜백을 쓸 때 이메일 템플릿도 같이 바꿉니다.
<a href="{{ .RedirectTo }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">
Log in
</a>
이건 공식 문서에 직접 나온 예시와 같은 방향입니다. 실무적으로는 “redirectTo를 넣었는데 왜 안 먹지?”라는 질문의 상당수가 여기서 끝납니다.
5. signInWithOtp()가 성공했는데 session이 비어 있어서 당황하는 문제
OTP 문서에서 이 부분도 명확합니다. 이메일 OTP 요청 1단계가 성공해도 data.user와 data.session은 null일 수 있습니다. 문서는 이 상태에서 사용자가 메일 inbox를 확인하도록 안내하라고 설명합니다.
즉 이 코드는 성공입니다.
const { data, error } = await supabase.auth.signInWithOtp({
email,
});
console.log(data.user); // null
console.log(data.session); // null
이걸 보고 “로그인이 실패했나?”라고 판단하면 오해입니다. 아직 인증의 1단계만 끝난 상태이기 때문입니다.
- Magic Link라면 사용자가 메일의 링크를 클릭해야 합니다.
- OTP라면 사용자가 6자리 코드를 입력하고
verifyOtp()를 호출해야 합니다.
OTP 검증은 이렇게 진행됩니다.
const {
data: { session },
error,
} = await supabase.auth.verifyOtp({
email,
token,
type: "email",
});
즉 실무적 해석은 이렇습니다. signInWithOtp()는 “로그인 완료”가 아니라 “인증 시작”입니다.
6. Magic Link 콜백 라우트를 안 만들어서 세션 교환이 끊기는 문제
Supabase의 passwordless 문서는 PKCE 흐름을 쓸 때 이메일 템플릿을 token_hash 기반 링크로 바꾸고, /auth/confirm 같은 엔드포인트에서 verifyOtp()로 세션을 교환하라고 설명합니다. 이건 App Router와 SSR을 쓰는 Next.js에서 특히 중요합니다.
정리하면 Magic Link 흐름은 보통 이렇게 됩니다.
- 브라우저에서
signInWithOtp()호출 - 메일 템플릿이
token_hash가 포함된 링크 생성 - 사용자가
/auth/confirm?...로 이동 - 서버 측 또는 안전한 콜백 라우트에서
verifyOtp()실행 - 세션 생성 후 최종 페이지로 redirect
App Router 기준으로는 Route Handler를 두는 편이 명확합니다.
src/app/auth/confirm/route.ts
import { NextResponse } from "next/server";
import type { EmailOtpType } from "@supabase/supabase-js";
import { createClient } from "@/lib/supabase/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const token_hash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
const next = searchParams.get("next") ?? "/dashboard";
if (!token_hash || !type) {
return NextResponse.redirect(`${origin}/login?error=missing_token`);
}
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
token_hash,
type,
});
if (error) {
return NextResponse.redirect(`${origin}/login?error=auth_failed`);
}
return NextResponse.redirect(`${origin}${next}`);
}
이 흐름을 안 만들면, 링크는 도착했는데 세션은 제대로 안 잡히는 이상한 상태가 생기기 쉽습니다.
7. Next.js App Router에서 오래된 auth-helpers 예제를 그대로 따라가는 문제
Supabase의 최신 SSR 문서는 @supabase/ssr를 기준으로 설명하고 있고, 별도 migration/troubleshooting 문서에서도 auth-helpers 패키지가 deprecated이며 앞으로의 버그 수정과 기능 릴리스는 @supabase/ssr에 집중된다고 명시합니다.
그래서 오래된 블로그 글을 따라 하며 아래 조합을 섞기 시작하면 문제가 늘어납니다.
@supabase/auth-helpers-nextjs- 오래된
anonkey 예제 - App Router 이전 방식
- localStorage 중심 세션 가정
현재 문서 기준으로 Next.js에서는 @supabase/ssr를 기준으로 browser client와 server client를 나누는 편이 맞습니다.
src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
}
그리고 서버 쪽은 cookies 기반으로 별도 client를 둡니다. 여기서 또 중요한 공식 문서 포인트가 하나 있습니다. Supabase는 서버 코드에서 getSession()을 신뢰하지 말고, getClaims()를 사용해 토큰을 검증하라고 안내합니다.
즉 이메일 로그인 구현이 꼬일 때는 로그인 폼만 볼 게 아니라, 전체 세션 저장 방식이 최신 SSR 가이드와 맞는지도 함께 봐야 합니다.
8. 메일을 눌렀는데 “Token has expired or is invalid”가 바로 뜨는 문제
이건 코드가 멀쩡해도 생길 수 있는 사례입니다. Supabase Email Templates 문서는 일부 이메일 제공자가 보안 검사 과정에서 링크를 미리 열어보는 prefetch를 할 수 있고, 이 경우 ConfirmationURL이 먼저 소비되어 사용자가 실제로 눌렀을 때 토큰이 이미 만료되거나 무효처럼 보일 수 있다고 설명합니다.
문서가 제안하는 대응은 크게 두 가지입니다.
{{ .Token }}를 이용한 Email OTP로 전환- 커스텀 링크를 만들어 사용자가 한 번 더 확인하는 페이지를 거친 뒤 검증
예를 들어 이렇게 단순한 “확인 페이지”를 먼저 거치게 할 수 있습니다.
<a href="{{ .SiteURL }}/confirm-signin">이메일 로그인을 계속합니다</a>
그리고 해당 페이지에서 사용자가 직접 코드를 입력하도록 하거나, confirmation_url 쿼리를 파싱해 한 번 더 클릭하게 만드는 식입니다.
이건 공식 문서에 직접 나온 제약이므로, 특정 메일 환경에서 magic link가 유난히 불안정하다면 구현 실수만 의심하지 말고 prefetch 가능성도 같이 보는 편이 좋습니다.
9. SSR 보호 페이지에서 세션을 잘못 읽어 “로그인했는데 로그아웃처럼 보이는” 문제
이건 이메일 로그인 그 자체보다, 이메일 로그인 이후 증상이 이상하게 보일 때 많이 부딪힙니다. 예를 들어 링크 클릭 직후에는 로그인된 것 같은데 새로고침하면 풀려 보이거나, 서버 페이지에선 비로그인인데 클라이언트에선 로그인처럼 보이는 상태입니다.
Supabase의 Next.js SSR 가이드는 이 부분을 꽤 강하게 경고합니다.
- Server Components는 쿠키를 직접 갱신할 수 없어서 proxy가 필요하다
- 서버 쪽 보호 로직에는
getClaims()를 쓰는 편이 안전하다 getSession()은 서버 코드에서 토큰 재검증을 보장하지 않는다
즉 이메일 로그인 후 이상한 세션 증상이 보이면 “이메일 링크가 잘못됐다”보다 “SSR session refresh 흐름이 완성됐나?”를 먼저 보는 편이 맞습니다.
제 실무적 해석으로는, App Router에서 이메일 로그인은 보내기 버튼보다 쿠키 기반 세션 갱신이 더 어려운 부분입니다.
10. 재전송과 만료 시간을 고려하지 않아 UX가 어색해지는 문제
Supabase 문서는 기본적으로 Magic Link와 OTP 요청은 60초에 한 번만 가능하고, 기본 만료 시간은 1시간이라고 설명합니다. 그래서 UI에서 이 제약을 모른 척하면 사용자는 금방 헷갈립니다.
- 연속 클릭했는데 아무 일도 없는 것처럼 느껴짐
- 이미 지난 메일을 다시 눌렀다가 실패
- 코드를 여러 번 재요청하다가 제한에 걸림
그래서 저는 폼에 아래 같은 문구를 같이 두는 편입니다.
- “메일은 최대 1분에 한 번 다시 보낼 수 있습니다.”
- “받은 링크 또는 코드는 1시간 안에 사용해 주세요.”
- “메일이 바로 오지 않으면 스팸함도 확인해 주세요.”
이건 프레임워크 문제가 아니라 운영 UX 문제지만, 실제 로그인 성공률에는 꽤 큰 영향을 줍니다.
제가 먼저 점검하는 체크리스트
- OTP를 원하는데 실제 이메일 템플릿이
{{ .Token }}기준으로 바뀌어 있는가 - 자동 가입을 원하지 않는다면
shouldCreateUser: false를 넣었는가 - Dashboard의
Site URL과 Redirect URLs가 실제 도메인과 맞는가 - 코드의
emailRedirectTo와 이메일 템플릿의{{ .RedirectTo }}가 같은 흐름을 가리키는가 signInWithOtp()후 바로 session이 없다고 실패로 오해하고 있지 않은가- Magic Link용
/auth/confirm라우트에서verifyOtp()를 수행하고 있는가 - Next.js에서는
@supabase/ssr기반 client/server 구성을 쓰고 있는가 - 서버 보호 로직에서
getSession()대신getClaims()를 고려했는가 - 특정 메일 환경에서 링크 prefetch 문제를 의심해봤는가
- 재전송 제한과 만료 시간을 UI에 안내하고 있는가
마무리
Supabase Auth 이메일 로그인은 API 표면만 보면 단순하지만, 실제로는 이메일 템플릿, redirect allow list, 콜백 라우트, SSR 세션 구성까지 여러 층이 맞물립니다. 그래서 삽질도 보통 한 군데서 끝나지 않고, “왜 메일은 왔는데 로그인은 안 되지?”처럼 흐름 전체가 어색한 형태로 나타납니다.
제일 효과가 큰 접근은 함수 하나를 바꾸기보다, 먼저 “Magic Link냐 OTP냐”, “자동 가입 허용 여부”, “최종 redirect 경로”, “세션을 어느 층에서 만들지”를 먼저 확정하는 것입니다. 이 네 가지만 초반에 맞춰도 Supabase Auth 이메일 로그인은 훨씬 덜 헷갈립니다.
참고 자료: