- Published on
Next.js 14 OAuth 콜백 400 invalid_state 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 로그인 연동을 Next.js 14(App Router)에서 붙이다 보면, 인증 제공자 화면까지는 잘 갔다가 콜백에서 갑자기 400 invalid_state로 떨어지는 경우가 많습니다. 이 에러는 단순히 “state 값이 틀렸다”가 아니라, state를 저장해둔 쪽(보통 쿠키나 세션)과 콜백 요청이 만나는 지점이 어긋났을 때 발생합니다.
특히 Next.js 14는 서버 컴포넌트/라우트 핸들러/미들웨어/엣지 런타임이 섞이면서, 전통적인 Express 기반 구현보다 쿠키 전파, 프록시 헤더, 도메인/프로토콜 정합성 이슈가 더 자주 터집니다.
이 글은 invalid_state를 “원인 후보 나열”이 아니라, 재현 가능한 진단 순서와 고쳐지는 설정/코드를 중심으로 정리합니다.
invalid_state가 의미하는 것
OAuth 2.0에서 state는 CSRF 방지용 난수 토큰입니다.
- 로그인 시작 시 서버가
state를 생성 - 이
state를 서버가 나중에 검증할 수 있는 곳(쿠키, 세션 스토어 등)에 저장 - 인증 제공자에게
state를 쿼리로 보냄 - 콜백에서
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/callback과https://example.com/api/auth/callback/차이www.example.comvsexample.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_URL과 NEXTAUTH_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-Proto가 https로 들어오는지 서버 로그로 확인하세요.
(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).*)"],
};
운영 환경 체크리스트(배포 후에도 안 잡힐 때)
- 콜백 요청에 쿠키가 포함되는지부터 확인
APP_ORIGIN같은 기준 URL이 실제 외부 URL과 일치하는지 확인- 프록시가
X-Forwarded-Proto를https로 넘기는지 확인 - 멀티 인스턴스라면 state 저장소가 공유되는지 확인(인메모리 금지)
- 사파리/웹뷰라면 SameSite 정책 때문에
sameSite조정이 필요한지 확인
추가로, PKCE까지 붙였다면 state는 통과했는데 토큰 교환에서 invalid_grant로 넘어지는 경우도 많습니다. 이때는 원인이 완전히 다르니 아래 글의 체크리스트가 도움이 됩니다.
결론
Next.js 14에서 OAuth 콜백 400 invalid_state는 대부분 “state 생성/검증 로직” 자체보다, 쿠키가 콜백까지 안전하게 전달되는지와 프록시 뒤에서 URL 정합성이 유지되는지에서 갈립니다.
- 로컬만 되고 배포에서 실패: 프록시 헤더,
NEXTAUTH_URL/origin, Secure 쿠키를 최우선 점검 - 간헐적으로 실패: 멀티 인스턴스/세션 휘발 또는 동시 로그인 덮어쓰기 의심
- 특정 브라우저/웹뷰에서만 실패: SameSite 정책과 도메인/서브도메인 쿠키 범위 점검
위의 진단 순서대로 확인하고, 서명된 쿠키 기반 state 저장 또는 NextAuth 설정 정합성만 잡아도 대부분의 invalid_state는 재발 없이 정리됩니다.