- Published on
NextAuth.js OAuth 401 - state·PKCE 오류 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 로그인은 겉으로는 signIn() 한 번이지만, 실제로는 여러 번의 리다이렉트와 쿠키 기반의 임시 상태 저장이 맞물립니다. NextAuth.js에서 흔히 보는 401은 단순히 “인증 실패”가 아니라, state 검증 실패, PKCE code_verifier 누락, 쿠키가 리다이렉트 사이에서 보존되지 않음, 콜백 URL/프록시 설정 불일치 같은 “흐름 깨짐”의 결과인 경우가 많습니다.
이 글은 NextAuth.js OAuth 로그인에서 401이 날 때, 특히 state와 PKCE 관련 오류를 빠르게 좁히고 고치는 방법을 실전 관점에서 정리합니다.
401이 의미하는 것: 토큰 교환 단계에서 무너진다
OAuth Authorization Code Flow(+ PKCE)의 핵심 단계는 대략 다음입니다.
- 앱이 Provider로 리다이렉트하면서
state(CSRF 방지)와 PKCEcode_challenge를 만든다 - Provider가
code와state를 들고 콜백 URL로 되돌아온다 - 앱이 서버에서
code를token endpoint로 교환하면서code_verifier를 함께 보낸다
NextAuth.js에서 401이 나는 시점은 보통 3번(토큰 교환) 또는 2번(state 검증)입니다. 그런데 개발자가 브라우저에서 보는 건 “401” 하나뿐이라, 원인 추적이 막히기 쉽습니다.
증상별로 나누는 빠른 분류
다음 분류로 접근하면 원인 후보가 급격히 줄어듭니다.
A. 콜백 직후 OAuthCallback에서 바로 실패한다
- state 관련 메시지(불일치, 누락) 또는 쿠키 관련 메시지가 같이 나오는 경우가 많습니다.
- 브라우저가 콜백 요청에 원래 저장해둔 쿠키를 보내지 못한 상태일 확률이 높습니다.
B. 콜백은 들어오는데 토큰 교환에서 401이 난다
- Provider가
invalid_grant,PKCE verification failed,code_verifier missing같은 응답을 주는 경우가 많습니다. - 즉,
code자체는 왔지만 PKCE 검증에 필요한code_verifier가 서버에 없거나, 다른 값으로 저장되어 있습니다.
NextAuth 디버깅: 로그 레벨부터 올리기
원인 파악의 70%는 “정확히 어디서 실패했는지”를 보는 것입니다. NextAuth는 로그 설정이 가능하니 먼저 켭니다.
// auth.ts 또는 [...nextauth].ts
import NextAuth from "next-auth";
export const { handlers, auth } = NextAuth({
debug: true,
logger: {
error(code, metadata) {
console.error("NEXTAUTH_ERROR", code, metadata);
},
warn(code) {
console.warn("NEXTAUTH_WARN", code);
},
debug(code, metadata) {
console.log("NEXTAUTH_DEBUG", code, metadata);
},
},
providers: [
// ...
],
});
또한 서버(또는 서버리스)에서 Provider 토큰 엔드포인트로 실제 어떤 요청이 나가는지 확인하려면, 프록시/게이트웨이 로그나 egress 로그도 같이 봐야 합니다.
state 오류의 본질: 리다이렉트 사이 쿠키가 끊겼다
NextAuth는 state를 쿠키에 저장해두고, 콜백에서 그 쿠키와 Provider가 돌려준 state를 비교합니다. 따라서 다음 중 하나면 state 검증이 깨집니다.
- 콜백 요청에 state 쿠키가 실리지 않음
- 다른 도메인/서브도메인으로 이동하면서 쿠키 스코프가 달라짐
SameSite정책 때문에 크로스 사이트 리다이렉트에서 쿠키가 차단됨- HTTP/HTTPS 혼용으로
Secure쿠키가 제외됨
가장 흔한 케이스 1: 개발/프리뷰 환경에서 도메인이 바뀐다
예를 들어
- 로그인 시작은
https://preview-123.example.com - 콜백은
https://example.com/api/auth/callback/...
처럼 호스트가 바뀌면 쿠키가 끊길 수 있습니다. 특히 Vercel Preview, CloudFront, Nginx 리버스 프록시 구성에서 자주 발생합니다.
해결 체크리스트
- 로그인 시작 URL과 콜백 URL이 동일한 오리진인지 확인
- Provider 콘솔에 등록된 Redirect URI가 실제 런타임과 일치하는지 확인
- NextAuth의 베이스 URL이 올바른지 확인
# 예: 프로덕션
NEXTAUTH_URL="https://app.example.com"
# 프록시 뒤에서 외부 URL이 따로 있다면
NEXTAUTH_URL="https://app.example.com"
App Router를 쓰면서 배포 환경에서 URL 추론이 흔들리는 경우가 있어, 명시적으로
NEXTAUTH_URL을 잡아주는 것이 안전합니다.
가장 흔한 케이스 2: SameSite 설정으로 쿠키가 리다이렉트에서 누락된다
최신 브라우저는 크로스 사이트 컨텍스트에서 쿠키를 매우 엄격하게 다룹니다. OAuth는 “외부 Provider로 갔다가 돌아오는” 구조라 SameSite 영향이 큽니다.
- 일반적으로 OAuth 콜백 흐름에는
SameSite=Lax가 잘 맞는 편입니다. - 일부 특수한 임베디드/서드파티 컨텍스트(인앱 브라우저, iframe 등)에서는
SameSite=None; Secure가 필요할 수 있습니다.
NextAuth 버전과 설정 방식에 따라 쿠키 커스터마이즈가 가능합니다.
import NextAuth from "next-auth";
export const { handlers, auth } = NextAuth({
cookies: {
sessionToken: {
name: "__Secure-next-auth.session-token",
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
// ...
});
주의할 점은 secure: true는 HTTPS에서만 쿠키가 전송된다는 것입니다. 로컬에서 HTTPS가 아니라면 개발 환경에선 secure: false가 필요할 수 있습니다.
PKCE 오류의 본질: code_verifier를 못 찾거나 다른 값을 쓴다
PKCE는 Provider가 “이 code를 요청한 클라이언트가 맞는지”를 검증하기 위해
- 시작 시
code_verifier를 만들고 - Provider로 보낼 때는
code_challenge만 노출 - 콜백 후 토큰 교환 때
code_verifier를 다시 제출 하는 방식입니다.
NextAuth는 이 code_verifier를 보통 쿠키에 저장합니다. 따라서 PKCE 오류도 결국 “쿠키가 끊겼다”로 이어지는 경우가 많습니다.
흔한 PKCE 실패 패턴
- 콜백 요청에 PKCE 관련 쿠키가 안 실림
- 여러 탭에서 동시에 로그인 시도해서 최신 값으로 덮어써짐
- 프록시가
Set-Cookie를 변형/제거 - Edge/Serverless 환경에서 헤더 크기 제한으로 쿠키가 잘림
재현이 어려운 “여러 탭 로그인” 문제
사용자가 로그인 버튼을 연속 클릭하거나, 새 탭에서 다시 로그인하면
- 첫 번째 시도의
code_verifier가 쿠키에 저장 - 두 번째 시도가 같은 쿠키 키를 덮어씀
- 첫 번째 콜백이 돌아왔을 때는 이미 verifier가 바뀌어 검증 실패
대응
- 로그인 버튼 중복 클릭 방지(UI 레벨)
- 로그인 시작 시점에 이미 진행 중이면 막기
- 가능하면 provider별로 flow를 분리하거나, 최신 NextAuth 버전에서 관련 이슈가 해결됐는지 확인
프론트에서 최소한의 가드만 해도 실패율이 크게 줄어듭니다.
let signingIn = false;
export async function safeSignIn(provider: string) {
if (signingIn) return;
signingIn = true;
try {
const { signIn } = await import("next-auth/react");
await signIn(provider);
} finally {
signingIn = false;
}
}
리버스 프록시/로드밸런서 환경에서 자주 터지는 설정
OAuth는 리다이렉트와 쿠키가 핵심이라, 인프라 레이어의 “사소한” 설정 차이가 바로 state/PKCE 실패로 이어집니다.
1) X-Forwarded-Proto가 빠져 HTTPS 인식이 깨짐
프록시 뒤에서 NextAuth가 자신을 HTTP로 인식하면
- 콜백 URL 생성이 틀어지고
Secure쿠키가 의도대로 세팅되지 않거나- 리다이렉트가 혼용되어 쿠키가 누락 될 수 있습니다.
Nginx 예시는 다음을 확인합니다.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
Kubernetes Ingress, ALB Ingress Controller, CloudFront를 쓰는 경우에도 동일하게 “외부에서 들어온 스킴과 호스트”가 앱까지 전달되는지 확인해야 합니다.
2) 쿠키 도메인/패스가 의도와 다르다
app.example.com과 api.example.com을 섞어 쓰거나, /가 아닌 path로 서비스하는 경우 쿠키 스코프가 어긋납니다.
- 가능하면 인증 시작과 콜백을 같은 호스트에서 처리
- 부득이하면 쿠키 옵션을 명시적으로 맞추기
Provider 설정에서 점검할 것들
Provider 콘솔에서 다음이 틀리면, state/PKCE가 정상이어도 401이 납니다.
- Redirect URI 정확히 일치(스킴, 호스트, path, trailing slash)
- 앱 타입(웹/네이티브)과 Flow 설정 일치
- PKCE 강제 여부 확인(Provider가 PKCE 필수인데 클라이언트가 미지원이면 실패)
- Client Secret/Client ID가 환경별로 섞이지 않았는지
특히 환경변수 실수로
- 프리뷰는 A Provider 앱
- 실제 콜백은 B Provider 앱 으로 교차되면
invalid_client또는401이 자주 나옵니다.
실전 트러블슈팅 순서(시간 절약 루틴)
현장에서 가장 빠른 순서는 보통 이렇습니다.
- 브라우저 개발자도구에서 콜백 요청을 확인
- Request URL이 예상한 도메인인지
- 콜백 요청에 쿠키가 실렸는지
- 서버 로그에서 NextAuth debug 로그 확인
- state/PKCE 관련 키워드 확인
- Provider 토큰 엔드포인트 응답 확인
invalid_grant면 PKCE/코드 만료/중복 시도invalid_client면 클라이언트 인증(시크릿/인증 방식)
- 프록시 헤더 확인
X-Forwarded-Proto/Host
- 환경변수 확인
NEXTAUTH_URL및 Provider 관련 값이 환경별로 정확한지
이 루틴은 “애플리케이션 버그”인지 “인프라/브라우저 정책”인지 빠르게 가릅니다. 장애 대응 관점의 빠른 진단 프레임은 Kubernetes CrashLoopBackOff 원인 12가지와 진단 글의 접근 방식과도 유사합니다.
코드 예제: NextAuth 설정에서 흔한 실수 줄이기
아래는 App Router 기반에서 Google을 예로 든 최소 구성입니다. 핵심은
NEXTAUTH_URL을 명시trustHost(버전에 따라 필요)로 프록시 환경에서 호스트 신뢰debug로깅 입니다.
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
export const { handlers, auth } = NextAuth({
debug: true,
trustHost: true,
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: { strategy: "jwt" },
});
export const GET = handlers.GET;
export const POST = handlers.POST;
환경변수는 다음처럼 맞춥니다.
NEXTAUTH_URL="https://app.example.com"
NEXTAUTH_SECRET="long-random-secret"
GOOGLE_CLIENT_ID="..."
GOOGLE_CLIENT_SECRET="..."
NEXTAUTH_SECRET가 환경마다 바뀌면(또는 누락되면) JWT/쿠키 암호화 키가 달라져 세션/상태가 깨지는 형태로도 문제가 나타날 수 있으니 반드시 고정하세요.
체크리스트: state·PKCE 401을 끝내는 12가지 점검 항목
- 로그인 시작 URL과 콜백 URL이 같은 오리진인지
NEXTAUTH_URL이 실제 외부 URL과 일치하는지- 프록시가
X-Forwarded-Proto/Host를 올바르게 전달하는지 - HTTPS 강제 환경에서 쿠키가
Secure로 세팅되는지 SameSite정책이 리다이렉트 흐름을 막지 않는지- Provider Redirect URI가 완전 일치하는지
- Provider 앱(클라이언트 ID/시크릿)이 환경별로 섞이지 않았는지
- 여러 탭/중복 클릭으로 PKCE verifier가 덮어써지지 않는지
- 인앱 브라우저/iframe 같은 특수 컨텍스트인지
- 서버리스/엣지에서 헤더(쿠키) 크기 제한에 걸리지 않는지
NEXTAUTH_SECRET이 고정되어 있는지- NextAuth 버전 이슈(릴리즈 노트/이슈 트래커)를 확인했는지
운영 팁: “간헐적 401”을 장애로 키우지 않기
간헐적 401은 재현이 어려워서 장기화되기 쉽습니다. 다음을 추천합니다.
- 콜백 실패 시 서버 로그에
requestId를 남기고, Provider 응답 바디(민감정보 제외)를 함께 기록 - 프론트에서 로그인 실패 이벤트를 Sentry 같은 곳에 수집(브라우저/인앱 여부, referrer, 오리진)
- 배포/프록시 변경 시 인증 플로우를 회귀 테스트 항목으로 고정
트래픽이 증가하면 “가끔 실패”가 “매일 장애”로 보이기 시작합니다. 레이트리밋/외부 API 실패를 체계적으로 다루는 방식은 Python httpx ReadTimeout·ConnectError 재시도 설계 같은 글의 관점도 참고할 만합니다.
마무리
NextAuth.js의 OAuth 401은 Provider가 나쁘다기보다, 대부분 state/PKCE를 저장해둔 쿠키가 리다이렉트 사이에서 보존되지 않거나, 프록시/도메인/HTTPS 인식이 어긋나면서 흐름이 끊기는 문제입니다.
해결의 핵심은
- 콜백 요청에 쿠키가 실리는지 확인하고
NEXTAUTH_URL과 프록시 헤더를 바로잡고- SameSite/Secure 정책을 환경에 맞게 조정하며
- 중복 로그인 시도를 제어 하는 것입니다.
이 네 가지만 제대로 잡아도, state·PKCE 기반의 간헐적 401은 대부분 사라집니다.