Published on

Next.js 14 OAuth 콜백 400 invalid_state 해결법

Authors

서드파티 OAuth 로그인 연동을 Next.js 14(App Router)에서 붙이다 보면, 인증 제공자 화면까지는 잘 갔다가 콜백에서 갑자기 400 invalid_state로 떨어지는 경우가 많습니다. 이 에러는 단순히 “state 값이 틀렸다”가 아니라, state를 저장해둔 쪽(보통 쿠키나 세션)과 콜백 요청이 만나는 지점이 어긋났을 때 발생합니다.

특히 Next.js 14는 서버 컴포넌트/라우트 핸들러/미들웨어/엣지 런타임이 섞이면서, 전통적인 Express 기반 구현보다 쿠키 전파, 프록시 헤더, 도메인/프로토콜 정합성 이슈가 더 자주 터집니다.

이 글은 invalid_state를 “원인 후보 나열”이 아니라, 재현 가능한 진단 순서고쳐지는 설정/코드를 중심으로 정리합니다.

invalid_state가 의미하는 것

OAuth 2.0에서 state는 CSRF 방지용 난수 토큰입니다.

  1. 로그인 시작 시 서버가 state를 생성
  2. state서버가 나중에 검증할 수 있는 곳(쿠키, 세션 스토어 등)에 저장
  3. 인증 제공자에게 state를 쿼리로 보냄
  4. 콜백에서 state가 돌아오면, 저장된 값과 비교

여기서 콜백 시점에

  • 저장된 state를 읽을 수 없거나(쿠키 미전달)
  • 다른 값으로 덮였거나(동시 로그인 탭, 레이스)
  • 저장은 되었는데 다른 도메인/경로로 저장되어 접근 못 하거나
  • 프록시 때문에 콜백 URL이 달라져서(프로토콜/호스트 불일치) 라이브러리가 다른 쿠키 키로 보거나

하면 invalid_state가 납니다.

Next.js 14에서 특히 자주 터지는 6가지 원인

1) 쿠키가 콜백 요청에 안 실려옴(SameSite, Secure, Domain)

가장 흔한 케이스입니다. state를 쿠키에 저장하는 라이브러리(예: NextAuth, Auth.js, 자체 구현)가 많기 때문입니다.

체크 포인트:

  • HTTPS인데 쿠키에 secure가 빠져 있거나(또는 반대로 HTTP인데 secure가 켜짐)
  • SameSite=Lax로는 되는데 특정 플로우에서 None이 필요하거나
  • Domain을 잘못 지정해 app.example.com에서 만든 쿠키가 example.com 콜백에서 안 보이거나
  • Path가 너무 좁게 잡혀 콜백 경로에서 쿠키가 안 보이거나

진단 방법(가장 빠른 방법):

  • 브라우저 개발자 도구에서 로그인 시작 직후 쿠키가 생성되는지 확인
  • 콜백 요청(Network 탭)에서 Request Headers에 cookie가 포함되는지 확인

2) 프록시/로드밸런서 뒤에서 프로토콜/호스트가 뒤틀림

로컬에서는 되는데 배포(특히 Nginx, ALB, Cloudflare)에서만 터지면 이 케이스가 매우 유력합니다.

  • 앱은 내부적으로 HTTP로 보이는데, 외부는 HTTPS
  • x-forwarded-proto, x-forwarded-host가 누락/오염
  • 콜백 URL을 만들 때 내부 호스트를 기준으로 만들어 provider 설정과 불일치

이 경우 state 자체가 틀렸다기보다, 쿠키를 굽는 기준(origin)콜백이 들어오는 기준(origin) 이 달라져 쿠키가 매칭되지 않는 문제가 자주 발생합니다.

프록시 환경에서 302가 꼬이면 로그인 플로우가 계속 반복되기도 하므로, 아래 글도 함께 보면 원인 파악이 빨라집니다.

3) 서버리스/엣지에서 세션 저장소가 휘발됨

state를 서버 메모리에 저장하는 방식(예: 인메모리 세션)을 쓰면, 서버리스/스케일아웃 환경에서 콜백이 다른 인스턴스로 들어가면서 state를 못 찾습니다.

  • 로컬 단일 프로세스에서는 정상
  • 배포 후 트래픽 분산되면 간헐적으로 invalid_state

해결은 공유 가능한 저장소(쿠키 기반, Redis, DB) 로 바꾸는 것입니다.

4) 동시에 로그인 시도(탭 2개, 더블클릭)로 state가 덮임

사용자가 로그인 버튼을 두 번 누르거나, 두 탭에서 동시에 로그인하면 마지막 state가 쿠키/세션에 저장되고, 먼저 시작한 플로우가 돌아왔을 때 state mismatch가 납니다.

해결 아이디어:

  • 로그인 시작 버튼을 클릭 후 disabled 처리
  • state를 단일 값이 아니라 “여러 개 허용” 형태로 저장(짧은 TTL의 리스트)
  • 콜백 처리 시 한 번 쓰면 즉시 폐기

5) 콜백 라우트가 캐시/리라이트/미들웨어에 의해 변형됨

Next.js 14에서 미들웨어가 모든 경로에 적용되면, 콜백 경로가 리라이트되거나 헤더가 바뀌는 부작용이 생길 수 있습니다.

  • middleware.ts에서 인증 콜백 경로를 예외 처리하지 않음
  • 콜백에서 쿠키를 읽어야 하는데, 엣지 런타임 제약으로 라이브러리가 기대대로 동작하지 않음

6) 잘못된 redirect_uri 정합성(슬래시, 포트, www)

일부 라이브러리는 redirect_uri/origin을 기준으로 state 쿠키 키를 결정하거나, 콜백 검증 시 호스트 비교를 합니다.

  • https://example.com/api/auth/callbackhttps://example.com/api/auth/callback/ 차이
  • www.example.com vs example.com
  • :3000 포함 여부

재현 가능한 진단 순서(로그를 남기면서 좁히기)

아래 순서대로 보면, 대부분 10~20분 안에 원인이 좁혀집니다.

1) 콜백 요청에 쿠키가 실리는지부터 확인

콜백 요청이 GET /api/auth/callback/... 같은 형태라면, Network 탭에서 Request Headers의 cookie를 봅니다.

  • 쿠키가 없다: SameSite/Secure/Domain/Path 또는 프록시/도메인 불일치가 유력
  • 쿠키는 있는데 state mismatch: 동시 로그인/덮어쓰기, 또는 저장소 휘발 가능성

2) x-forwarded-proto와 실제 프로토콜이 일치하는지 확인

Nginx/ALB 뒤라면, 서버가 보는 프로토콜이 HTTP로 고정되는 경우가 많습니다. 이때 라이브러리가 생성하는 callback URL이 달라질 수 있습니다.

3) 배포 환경에서 인스턴스가 여러 개인지 확인

invalid_state가 “간헐적”이면 가장 먼저 의심합니다.

4) 미들웨어/리라이트 예외 처리 확인

콜백 경로는 가능한 한 “가공하지 않는 경로”로 두는 게 안전합니다.

해결 패턴 1: NextAuth(Auth.js)에서 쿠키/프록시 정합성 잡기

NextAuth를 쓰는 경우, invalid_state는 대개 쿠키 옵션과 프록시 헤더에서 해결됩니다.

(1) NEXTAUTH_URLNEXTAUTH_SECRET 고정

배포 환경에서 반드시 고정하세요.

# .env.production
NEXTAUTH_URL=https://example.com
NEXTAUTH_SECRET=your-long-random-secret
  • NEXTAUTH_URL이 실제 외부 URL과 다르면, 쿠키/리다이렉트가 꼬일 수 있습니다.

(2) 프록시 뒤라면 trust 설정과 forwarded 헤더 확인

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;

Cloudflare나 ALB 조합이면, 실제로는 X-Forwarded-Protohttps로 들어오는지 서버 로그로 확인하세요.

(3) 쿠키 SameSite/Secure 튜닝(필요한 경우)

기본값으로 되는 게 이상적이지만, 일부 환경(커스텀 도메인, 임베디드 브라우저, 사파리 특정 정책 등)에서는 조정이 필요합니다.

NextAuth v5 계열(Auth.js) 기준으로는 설정 구조가 바뀔 수 있으니 사용 버전에 맞게 적용하세요. 개념적으로는 다음을 점검합니다.

  • HTTPS면 secure: true
  • 크로스 사이트 컨텍스트가 강하면 sameSite: "none" 필요

주의: sameSite: "none"은 반드시 secure: true와 함께여야 합니다.

해결 패턴 2: 자체 OAuth 구현에서 state를 “서명된 쿠키”로 안전하게 저장

세션 스토어 없이도 구현하려면, state를 서버에서 생성하고 서명된 쿠키에 저장한 뒤 콜백에서 비교하는 방식이 단순하고 강합니다.

아래 예시는 Next.js 14 Route Handler에서 동작하는 형태입니다.

(1) 로그인 시작 라우트: state 생성 후 쿠키 저장

// app/api/oauth/start/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import crypto from "crypto";

function base64url(input: Buffer) {
  return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

function sign(value: string, secret: string) {
  const sig = crypto.createHmac("sha256", secret).update(value).digest();
  return `${value}.${base64url(sig)}`;
}

export async function GET() {
  const secret = process.env.OAUTH_STATE_SECRET!;
  const state = base64url(crypto.randomBytes(32));
  const signed = sign(state, secret);

  cookies().set({
    name: "oauth_state",
    value: signed,
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 10,
  });

  const redirectUri = `${process.env.APP_ORIGIN}/api/oauth/callback`;
  const authUrl = new URL("https://provider.example.com/oauth/authorize");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("client_id", process.env.OAUTH_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", redirectUri);
  authUrl.searchParams.set("state", state);

  return NextResponse.redirect(authUrl.toString());
}

포인트:

  • 쿠키 path는 보통 /로 넓게
  • 배포가 HTTPS면 secure: true
  • state TTL은 짧게(5~10분)

(2) 콜백 라우트: 쿠키 state와 쿼리 state 비교

// app/api/oauth/callback/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import crypto from "crypto";

function base64url(input: Buffer) {
  return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

function verify(signed: string, secret: string) {
  const parts = signed.split(".");
  if (parts.length !== 2) return null;
  const [value, sig] = parts;
  const expected = base64url(crypto.createHmac("sha256", secret).update(value).digest());
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ? value : null;
}

export async function GET(req: Request) {
  const url = new URL(req.url);
  const returnedState = url.searchParams.get("state");
  const code = url.searchParams.get("code");

  const cookie = cookies().get("oauth_state")?.value;
  const secret = process.env.OAUTH_STATE_SECRET!;

  if (!returnedState || !code || !cookie) {
    return NextResponse.json({ error: "invalid_state", reason: "missing" }, { status: 400 });
  }

  const storedState = verify(cookie, secret);
  if (!storedState || storedState !== returnedState) {
    return NextResponse.json({ error: "invalid_state", reason: "mismatch" }, { status: 400 });
  }

  // 1회성 사용을 위해 즉시 폐기
  cookies().set({ name: "oauth_state", value: "", path: "/", maxAge: 0 });

  // 여기서 code를 토큰으로 교환
  // ...

  return NextResponse.redirect(`${process.env.APP_ORIGIN}/login/success`);
}

이 방식으로도 invalid_state가 난다면, 거의 확실하게 “쿠키가 콜백에 전달되지 않는 문제”이므로 SameSite/Secure/Domain/프록시를 다시 봐야 합니다.

해결 패턴 3: 동시 로그인/더블클릭으로 state 덮임 방지

현업에서 은근히 많이 발생합니다. 특히 모바일 웹뷰에서 더블탭이 잦습니다.

클라이언트에서 최소한의 방어를 추가합니다.

// app/login/LoginButton.tsx
"use client";

import { useState } from "react";

export function LoginButton() {
  const [busy, setBusy] = useState(false);

  return (
    <button
      disabled={busy}
      onClick={() => {
        if (busy) return;
        setBusy(true);
        window.location.href = "/api/oauth/start";
      }}
    >
      Continue with OAuth
    </button>
  );
}

서버 측에서도 state를 “단일 슬롯”이 아니라 “짧은 리스트”로 저장하면 더 견고해집니다(쿠키 크기 제한을 고려해 2~3개 정도).

미들웨어를 쓴다면 콜백 경로 예외 처리

인증 미들웨어가 콜백까지 가로채면, 쿠키/리다이렉트가 꼬여 state 검증이 실패할 수 있습니다.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // OAuth 콜백/시작 경로는 그대로 통과
  if (pathname.startsWith("/api/oauth/")) {
    return NextResponse.next();
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

운영 환경 체크리스트(배포 후에도 안 잡힐 때)

  1. 콜백 요청에 쿠키가 포함되는지부터 확인
  2. APP_ORIGIN 같은 기준 URL이 실제 외부 URL과 일치하는지 확인
  3. 프록시가 X-Forwarded-Protohttps로 넘기는지 확인
  4. 멀티 인스턴스라면 state 저장소가 공유되는지 확인(인메모리 금지)
  5. 사파리/웹뷰라면 SameSite 정책 때문에 sameSite 조정이 필요한지 확인

추가로, PKCE까지 붙였다면 state는 통과했는데 토큰 교환에서 invalid_grant로 넘어지는 경우도 많습니다. 이때는 원인이 완전히 다르니 아래 글의 체크리스트가 도움이 됩니다.

결론

Next.js 14에서 OAuth 콜백 400 invalid_state는 대부분 “state 생성/검증 로직” 자체보다, 쿠키가 콜백까지 안전하게 전달되는지프록시 뒤에서 URL 정합성이 유지되는지에서 갈립니다.

  • 로컬만 되고 배포에서 실패: 프록시 헤더, NEXTAUTH_URL/origin, Secure 쿠키를 최우선 점검
  • 간헐적으로 실패: 멀티 인스턴스/세션 휘발 또는 동시 로그인 덮어쓰기 의심
  • 특정 브라우저/웹뷰에서만 실패: SameSite 정책과 도메인/서브도메인 쿠키 범위 점검

위의 진단 순서대로 확인하고, 서명된 쿠키 기반 state 저장 또는 NextAuth 설정 정합성만 잡아도 대부분의 invalid_state는 재발 없이 정리됩니다.