Published on

NextAuth.js OAuth 401 - state·PKCE 오류 해결

Authors

서드파티 OAuth 로그인은 겉으로는 signIn() 한 번이지만, 실제로는 여러 번의 리다이렉트와 쿠키 기반의 임시 상태 저장이 맞물립니다. NextAuth.js에서 흔히 보는 401은 단순히 “인증 실패”가 아니라, state 검증 실패, PKCE code_verifier 누락, 쿠키가 리다이렉트 사이에서 보존되지 않음, 콜백 URL/프록시 설정 불일치 같은 “흐름 깨짐”의 결과인 경우가 많습니다.

이 글은 NextAuth.js OAuth 로그인에서 401이 날 때, 특히 statePKCE 관련 오류를 빠르게 좁히고 고치는 방법을 실전 관점에서 정리합니다.

401이 의미하는 것: 토큰 교환 단계에서 무너진다

OAuth Authorization Code Flow(+ PKCE)의 핵심 단계는 대략 다음입니다.

  1. 앱이 Provider로 리다이렉트하면서 state(CSRF 방지)와 PKCE code_challenge를 만든다
  2. Provider가 codestate를 들고 콜백 URL로 되돌아온다
  3. 앱이 서버에서 codetoken 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.comapi.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이 자주 나옵니다.

실전 트러블슈팅 순서(시간 절약 루틴)

현장에서 가장 빠른 순서는 보통 이렇습니다.

  1. 브라우저 개발자도구에서 콜백 요청을 확인
    • Request URL이 예상한 도메인인지
    • 콜백 요청에 쿠키가 실렸는지
  2. 서버 로그에서 NextAuth debug 로그 확인
    • state/PKCE 관련 키워드 확인
  3. Provider 토큰 엔드포인트 응답 확인
    • invalid_grant면 PKCE/코드 만료/중복 시도
    • invalid_client면 클라이언트 인증(시크릿/인증 방식)
  4. 프록시 헤더 확인
    • X-Forwarded-Proto/Host
  5. 환경변수 확인
    • 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은 대부분 사라집니다.