Published on

NextAuth OAuth 콜백 403·state mismatch 해결

Authors

서드파티 OAuth 로그인은 개발 환경에서는 잘 되다가도, 배포(특히 Vercel, Cloudflare, Nginx, ALB 같은 프록시/엣지)로 가면 갑자기 콜백이 403으로 떨어지거나 state mismatch가 터지는 경우가 많습니다. 원인은 대체로 단순합니다. NextAuth가 state를 쿠키에 저장해두고, 콜백 요청에서 그 쿠키를 다시 읽어 검증하는데, 쿠키가 누락되거나 다른 값으로 바뀌면 검증이 실패합니다.

이 글에서는 403state mismatch를 같은 문제군으로 묶어, 실제 운영에서 가장 흔한 원인과 해결책을 단계별로 정리합니다. (NextAuth v4 기준, App Router/Pages Router 모두 적용 가능한 형태로 작성)

증상 패턴: 403 vs state mismatch는 같은 계열

다음 중 하나라도 보이면 거의 동일한 진단 루틴으로 접근하면 됩니다.

  • OAuth 제공자(구글, 깃허브 등) 인증 후 돌아오자마자 403 응답
  • 서버 로그에 OAuthCallbackError 또는 state mismatch 문구
  • 콜백 URL은 정상인데, 세션 쿠키/CSRF/PKCE 관련 쿠키가 콜백 요청에 실리지 않음
  • 특정 브라우저(사파리, 모바일)에서만 재현

핵심은 이겁니다.

  • 로그인 시작 시점: NextAuth가 state(및 PKCE code verifier 등)를 쿠키로 저장
  • OAuth 제공자 인증 후 콜백 시점: NextAuth가 그 쿠키를 다시 읽어 state를 비교
  • 쿠키가 없거나 도메인/경로/보안 속성 때문에 안 오면 state mismatch 또는 403

원인 1) NEXTAUTH_URL 불일치(가장 흔함)

NEXTAUTH_URL은 NextAuth가 콜백 URL, 쿠키 도메인/보안 정책을 결정하는 중요한 기준입니다. 다음 같은 불일치가 있으면 쿠키가 예상과 다르게 설정되거나, 리다이렉트 URL이 꼬입니다.

  • 실제 접속은 https://app.example.com인데 NEXTAUTH_URLhttp://localhost:3000
  • www 유무가 다름 (https://example.com vs https://www.example.com)
  • 프록시 뒤에서 실제는 https인데 앱은 http로 인식

해결

배포 환경에서 반드시 실제 외부 URL로 맞춥니다.

# .env.production
NEXTAUTH_URL="https://app.example.com"
NEXTAUTH_SECRET="...긴 랜덤 문자열..."

또한 OAuth 제공자 콘솔(예: Google Cloud Console)의 Authorized redirect URI도 정확히 일치해야 합니다.

  • https://app.example.com/api/auth/callback/google

httphttps, www 유무, 경로까지 1글자라도 다르면 문제를 유발합니다.

원인 2) 프록시/로드밸런서에서 X-Forwarded-Proto 누락

Nginx, ALB, Cloudflare 같은 프록시 뒤에서는 앱이 원래 요청이 https였는지 http였는지 헤더로 판단합니다. 이 정보가 없으면 NextAuth가 쿠키를 secure로 설정하지 않거나, 반대로 secure 쿠키가 필요한데 잘못 판단해 쿠키가 안 붙는 상황이 생깁니다.

Nginx 예시 설정

location / {
  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;

  proxy_pass http://127.0.0.1:3000;
}

Cloudflare나 다른 CDN을 쓴다면, 원본 서버까지 X-Forwarded-Proto가 전달되는지 확인하세요.

원인 3) 쿠키 SameSite/도메인 문제로 콜백에 쿠키가 안 실림

state mismatch의 실질 원인은 “콜백 요청에 state 쿠키가 안 왔다”인 경우가 대부분입니다. 특히 아래 조건에서 자주 터집니다.

  • 커스텀 도메인 전환 중 (preview 도메인과 production 도메인 혼용)
  • 서로 다른 서브도메인 간 이동 (auth.example.com에서 시작해 app.example.com으로 콜백)
  • 사파리/모바일에서 ITP 영향으로 쿠키 정책이 더 엄격

진단 방법

Chrome DevTools에서 다음을 확인합니다.

  1. 로그인 시작 요청(예: /api/auth/signin/google) 응답에 Set-Cookie가 내려오는지
  2. 콜백 요청(예: /api/auth/callback/google)에 해당 쿠키가 실려 가는지

쿠키 이름은 환경/설정에 따라 다르지만, NextAuth 관련 쿠키(예: next-auth.state, next-auth.csrf-token, PKCE 관련 쿠키 등)가 로그인 시작 시점에 생성됩니다.

해결 1: 동일한 호스트에서 시작하고 동일한 호스트로 콜백 받기

가장 안정적인 해결책은 OAuth 플로우 전체를 한 호스트에서 끝내는 것입니다.

  • 시작: https://app.example.com/api/auth/signin/...
  • 콜백: https://app.example.com/api/auth/callback/...

서브도메인을 나누고 싶다면, 쿠키 도메인 정책을 명확히 해야 합니다.

해결 2: NextAuth 쿠키 설정 커스터마이즈

아래는 “운영에서 HTTPS, 프록시 뒤, 서브도메인 공유” 같은 조건에서 쿠키를 명시적으로 조정하는 예시입니다.

// pages/api/auth/[...nextauth].ts 또는 app 라우팅용 nextauth 설정 파일
import NextAuth from "next-auth";
import GoogleProvider from "next-auth/providers/google";

const isProd = process.env.NODE_ENV === "production";

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  secret: process.env.NEXTAUTH_SECRET,
  // 필요 시 쿠키 정책을 명시
  cookies: {
    sessionToken: {
      name: isProd ? "__Secure-next-auth.session-token" : "next-auth.session-token",
      options: {
        httpOnly: true,
        sameSite: "lax",
        path: "/",
        secure: isProd,
        // 서브도메인 공유가 필요하면 domain을 명시
        // domain: ".example.com",
      },
    },
  },
});

주의할 점

  • domain: ".example.com"을 넣으면 app.example.comauth.example.com이 쿠키를 공유할 수 있지만, 잘못 설정하면 오히려 쿠키 충돌/누락이 생깁니다.
  • sameSite를 무작정 none으로 바꾸면, 반드시 secure: true가 필요합니다.
  • OAuth 콜백은 “서드파티에서 돌아오는 top-level navigation”이라 보통 sameSite: "lax"로도 충분한 경우가 많습니다.

원인 4) 프리뷰 도메인과 프로덕션 도메인 혼용

Vercel preview, Cloudflare preview, 사내 스테이징 도메인을 함께 쓰면 다음 문제가 생깁니다.

  • 로그인 시작은 preview 도메인에서 했는데
  • OAuth 제공자 redirect URI는 production 도메인으로 설정되어 있고
  • 콜백은 production으로 들어오면서 preview에서 만든 쿠키가 당연히 없음

결과는 state mismatch 또는 403입니다.

해결

  • 환경별로 OAuth 앱을 분리(스테이징용 OAuth 클라이언트, 운영용 OAuth 클라이언트)
  • 환경별 NEXTAUTH_URL을 정확히 분리
  • preview 환경에서는 OAuth 기능을 제한하거나, preview 도메인도 redirect URI로 등록

원인 5) NEXTAUTH_SECRET 변경/누락으로 토큰 검증 실패

NEXTAUTH_SECRET이 배포마다 바뀌거나 누락되면, 쿠키/토큰 암복호화 검증이 실패해 403 계열로 떨어질 수 있습니다.

해결

  • 운영 환경에 고정된 NEXTAUTH_SECRET을 설정
  • 여러 인스턴스(스케일 아웃)에서 동일한 시크릿을 공유
# 한 번 생성한 값을 안전하게 보관하고 모든 인스턴스에 동일하게 주입
NEXTAUTH_SECRET="openssl rand -base64 32"  # 예시 명령

위 명령 자체를 그대로 쓰기보다, 실제로는 생성된 결과 문자열을 환경변수로 저장해 고정하세요.

원인 6) 시간 불일치(드물지만 치명적)

서버 시간이 크게 틀어지면 토큰 만료/검증 로직에서 문제가 날 수 있습니다. 특히 컨테이너/VM에서 NTP가 비활성화된 경우가 있습니다.

해결

  • 노드가 동작하는 OS/컨테이너의 시간 동기화(NTP) 확인
  • 쿠버네티스라면 노드 레벨 시간 동기화 확인

이 유형은 OAuth 자체의 state mismatch보다는 세션/토큰 403으로 나타나는 경우가 많습니다.

실전 디버깅: 로그와 네트워크에서 무엇을 봐야 하나

1) NextAuth 디버그 활성화

export default NextAuth({
  debug: process.env.NODE_ENV !== "production",
  // ...
});

개발/스테이징에서만 켜고, 운영에서는 민감 정보가 로그에 남지 않도록 주의합니다.

2) 브라우저에서 쿠키 흐름을 확인

  • /api/auth/signin/... 응답 헤더에 Set-Cookie가 내려오는지
  • 콜백 /api/auth/callback/... 요청 헤더에 Cookie가 실리는지
  • secure, sameSite, domain, path가 의도대로인지

이 과정에서 성능 이슈로 디버깅이 방해될 정도로 브라우저가 버벅인다면, 긴 태스크를 먼저 정리하는 것도 도움이 됩니다. 프론트가 멈추면 네트워크/스토리지 패널 확인 자체가 어려워지기 때문입니다. 필요하면 Chrome INP 급등? Long Task 추적·해결 가이드도 함께 참고하세요.

체크리스트: 콜백 403·state mismatch 10분 컷

  • NEXTAUTH_URL이 실제 외부 URL과 완전히 동일한가 (https, www, 경로)
  • OAuth 제공자 콘솔의 redirect URI가 정확한가
  • 프록시가 X-Forwarded-Proto를 전달하는가
  • 로그인 시작 도메인과 콜백 도메인이 동일한가
  • preview/production 도메인이 섞이지 않았는가
  • NEXTAUTH_SECRET이 고정되어 있고 모든 인스턴스에서 동일한가
  • 쿠키 secure/sameSite/domain 설정이 환경과 일치하는가
  • 특정 브라우저(사파리)에서만 문제면 쿠키 정책/ITP 영향을 의심했는가

결론: 쿠키가 끊기면 state가 끊긴다

NextAuth의 OAuth 콜백 403state mismatch는 대부분 “콜백에서 검증에 필요한 쿠키를 못 읽는다”로 귀결됩니다. 그래서 해결도 쿠키가 끊기는 지점을 찾는 방식이 가장 빠릅니다.

  • URL과 도메인을 정합하게 맞추고
  • 프록시 헤더를 올바르게 전달하고
  • 환경별 OAuth 설정을 분리하고
  • 필요할 때만 쿠키 옵션을 명시적으로 조정

이 4가지만 지켜도 운영에서의 OAuth 콜백 장애를 크게 줄일 수 있습니다.