- Published on
Node.js OAuth 콜백 400 state mismatch 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(OAuth 2.0 / OIDC)을 Node.js로 붙이다 보면, 인증 제공자에서 콜백이 돌아온 직후 400 에러와 함께 state mismatch가 터지는 순간이 있습니다. 보통은 라이브러리(예: openid-client, passport, next-auth)가 "요청 때 저장해둔 state"와 "콜백에 실려온 state"가 다르다고 판단할 때 발생합니다.
이 글은 state mismatch를 재현 가능한 형태로 분해하고, Node.js 서비스에서 실제로 많이 터지는 원인(세션 저장 실패, 쿠키 누락, 프록시 환경, 도메인/프로토콜 불일치, 멀티 인스턴스)별로 해결책을 제시합니다.
관련해서 400 에러를 구조적으로 진단하는 관점은 Claude Tool Use 400 오류 - schema·json 진단 가이드도 참고하면 좋습니다. "400을 원인별로 쪼개서" 보는 방식이 비슷합니다.
state가 왜 필요한가
OAuth 2.0에서 state는 주로 두 가지 목적을 가집니다.
- CSRF 방어: 내가 시작한 인증 플로우가 맞는지 검증
- 요청 컨텍스트 보존: 로그인 전 사용자가 보던 페이지, 테넌트 정보, PKCE verifier 식별자 등을 연결
일반적인 흐름은 다음과 같습니다.
- 로그인 시작 시 서버가 랜덤
state를 생성 - 서버가 그
state를 세션/쿠키/서버 저장소에 저장 - 제공자 authorize URL로 리다이렉트할 때
state를 쿼리에 포함 - 콜백에서
req.query.state와 저장된state를 비교
즉, state mismatch는 대부분 "콜백 요청이 들어왔는데 서버가 저장해둔 state를 찾지 못한다"로 귀결됩니다.
가장 흔한 원인 Top 7
아래는 실무에서 가장 자주 보는 원인들입니다. 한 번에 다 고치려 하지 말고, 로그로 증거를 모으면서 하나씩 배제하는 게 빠릅니다.
1) 콜백에서 세션이 새로 발급됨(세션 쿠키 누락)
- 로그인 시작 요청에서는 세션이 있었는데
- 제공자에서 돌아온 콜백 요청에는 세션 쿠키가 안 붙어서
- 서버가 "다른 사용자/새 세션"로 인식
대표 원인
SameSite설정 문제Secure설정 문제(HTTPS가 아닌데Secure강제)- 브라우저 정책(서드파티 쿠키 제한)
- 도메인/서브도메인 불일치
2) 프록시/로드밸런서 뒤에서 trust proxy 미설정
Express를 예로 들면, HTTPS로 서비스 중인데도 앱이 req.secure를 false로 판단하면 세션 쿠키를 다르게 굽거나(혹은 안 굽거나) 리다이렉트 URL 생성이 꼬일 수 있습니다.
3) 멀티 인스턴스에서 메모리 세션 사용
- 로그인 시작은 인스턴스 A
- 콜백은 인스턴스 B
- 메모리 세션이 공유되지 않아
state를 못 찾음
4) 콜백 URL/도메인/프로토콜 불일치
- authorize 요청은
https://app.example.com - 콜백은
https://example.com또는http://app.example.com
쿠키 스코프가 달라져 세션이 끊기고, 결과적으로 state mismatch가 납니다.
5) state를 여러 탭에서 동시에 시작(경합)
- 탭 A에서 로그인 시작(상태값
S1저장) - 탭 B에서 로그인 시작(상태값
S2로 덮어씀) - 탭 A 콜백이 먼저 돌아오면
S1vsS2mismatch
라이브러리에 따라 state를 "하나만" 저장하는 경우가 있어 특히 취약합니다.
6) 콜백 라우트에서 세션 미들웨어가 적용되지 않음
/auth/login에는session()이 적용/auth/callback에는 라우터 분리/순서 문제로session()이 미적용
그럼 저장된 state에 접근 자체가 안 됩니다.
7) 응답 헤더/쿠키가 중간에서 변조 또는 제거
- CDN/WAF가 쿠키를 제거
Set-Cookie가 크기 제한에 걸려 누락- 리다이렉트 체인 중 쿠키가 저장되지 않음
Kubernetes, EKS 환경에서는 인그레스/ALB 설정과 함께 보게 되는 경우가 많습니다. 네트워크 계층 점검 습관은 EKS에서 Pod는 정상인데 egress만 막힐 때 점검처럼 "경로를 단계별로" 보는 방식이 도움이 됩니다.
빠른 진단 체크리스트(로그로 확인)
아래 4가지만 찍어도 원인이 절반은 드러납니다.
- 로그인 시작 요청과 콜백 요청의 세션 ID가 같은가
- 콜백 요청에 쿠키가 실제로 들어왔는가(
Cookie헤더) - 서버가 생성한 authorize URL의
redirect_uri가 콜백과 완전히 일치하는가 - 인스턴스가 다르면 세션 저장소가 공유되는가
Express에서 최소 로그 예시
< > 문자는 MDX에서 빌드 에러를 유발할 수 있으니 본문에서는 사용하지 않습니다.
// app.js
import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
const app = express();
app.set('trust proxy', 1); // 프록시 뒤라면 중요
app.use(session({
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: true, // HTTPS라면 true
},
}));
app.get('/auth/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
console.log('[login] sid=', req.sessionID, 'state=', state);
const redirectUri = process.env.OAUTH_REDIRECT_URI;
const authorizeUrl = new URL(process.env.OAUTH_AUTHORIZE_URL);
authorizeUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('state', state);
res.redirect(authorizeUrl.toString());
});
app.get('/auth/callback', (req, res) => {
console.log('[callback] cookie=', req.headers.cookie || '(none)');
console.log('[callback] sid=', req.sessionID, 'saved=', req.session.oauthState);
console.log('[callback] state=', req.query.state);
if (!req.session.oauthState || req.query.state !== req.session.oauthState) {
return res.status(400).send('state mismatch');
}
res.send('ok');
});
app.listen(3000);
이 로그에서 콜백의 cookie가 비어 있거나 sid가 달라져 있으면, 세션 쿠키 문제로 좁혀집니다.
원인별 해결 가이드
1) SameSite와 Secure를 환경에 맞게 설정
OAuth 콜백은 "외부 사이트에서 내 사이트로 돌아오는 크로스 사이트 네비게이션"입니다. 이때 쿠키가 붙으려면 SameSite 정책을 이해해야 합니다.
- 일반적으로 최소 권장값은
SameSite=Lax입니다.- 대부분의 OAuth 리다이렉트(탑레벨 GET 네비게이션)에서 쿠키가 전송됩니다.
- 만약 제공자가
POST로 콜백을 보내거나, iframe/팝업 기반 흐름이면Lax로 부족할 수 있어SameSite=None이 필요할 수 있습니다.- 이때는 반드시
Secure=true가 필요합니다.
- 이때는 반드시
환경별 패턴
const isProd = process.env.NODE_ENV === 'production';
cookie: {
httpOnly: true,
secure: isProd, // 로컬 HTTP면 false
sameSite: isProd ? 'lax' : 'lax' // 필요 시 'none'
}
주의
- 로컬 개발에서 HTTPS가 아닌데
secure: true면 쿠키가 저장되지 않습니다. SameSite=None을 쓰면 보안상 더 민감해지므로 CSRF 방어 설계를 함께 점검하세요.
2) 프록시 뒤라면 trust proxy와 외부 URL 기준을 고정
Kubernetes Ingress, ALB, Nginx 뒤에서 동작할 때는 앱이 "내가 HTTPS로 서비스 중"이라는 사실을 헤더로만 알게 됩니다.
Express 기준
app.set('trust proxy', 1)설정- 프록시가
X-Forwarded-Proto: https를 전달하는지 확인
또한 redirect_uri는 요청에서 동적으로 조합하지 말고, 운영에서는 고정된 환경변수로 박아두는 편이 안전합니다.
const redirectUri = process.env.OAUTH_REDIRECT_URI; // 예: https://app.example.com/auth/callback
3) 멀티 인스턴스면 세션 저장소를 Redis 등으로
메모리 세션은 단일 프로세스에서만 동작합니다. 운영에서 스케일아웃하면 state mismatch가 매우 높은 확률로 발생합니다.
express-session + Redis 예시
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: true,
},
}));
이렇게 하면 로그인 시작이 어느 인스턴스에서 발생하든, 콜백에서 동일한 세션 데이터를 읽을 수 있습니다.
4) 탭 경합 방지: state를 1개가 아니라 "여러 개" 허용
로그인 버튼을 여러 번 누르거나 탭을 여러 개 열면 state가 덮어써지는 문제가 생깁니다. 해결책은 두 가지입니다.
- UI에서 로그인 버튼 연타 방지(클라이언트)
- 서버에서
state를 "최근 N개" 또는 "만료 포함 Set"으로 관리
간단한 서버 측 예시
function pushState(req, state) {
const arr = req.session.oauthStates || [];
arr.push({ state, ts: Date.now() });
// 최근 5개만 유지
req.session.oauthStates = arr.slice(-5);
}
function hasState(req, state) {
const arr = req.session.oauthStates || [];
const now = Date.now();
const ttlMs = 5 * 60 * 1000;
// TTL 지난 것 제거
req.session.oauthStates = arr.filter(x => now - x.ts < ttlMs);
return req.session.oauthStates.some(x => x.state === state);
}
콜백에서는 hasState(req, req.query.state)로 검증합니다.
5) 콜백 라우트에 세션 미들웨어가 적용되는지 확인
의외로 라우터 순서 때문에 콜백만 세션이 빠지는 경우가 있습니다.
나쁜 예
/auth라우터를app.use('/auth', authRouter)로 붙였는데session()은 그 아래가 아니라 다른 파일에서 조건부로 붙음
원칙
session()은 라우팅보다 먼저, 전역적으로 적용- 또는 최소한
/auth/callback보다 먼저 적용
6) 도메인/서브도메인/포트 정합성 확보
다음이 하나라도 다르면 쿠키 스코프가 달라질 수 있습니다.
app.example.comvsexample.comhttpsvshttp:3000포함 여부
운영에서 가장 안전한 방식
- 로그인 시작 URL과 콜백 URL을 모두 동일한 "정식 도메인"으로 통일
redirect_uri를 환경변수로 고정- 필요 시 쿠키에
domain: '.example.com'을 설정해 서브도메인 공유
cookie: {
domain: process.env.COOKIE_DOMAIN, // 예: .example.com
httpOnly: true,
sameSite: 'lax',
secure: true,
}
7) 라이브러리별 포인트: openid-client 사용 시
openid-client는 내부적으로 state와 nonce, PKCE verifier 등을 저장하고 콜백에서 검증합니다. 저장 매체는 보통 세션입니다.
패턴은 비슷합니다.
import { Issuer, generators } from 'openid-client';
const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
const client = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [process.env.OIDC_REDIRECT_URI],
response_types: ['code'],
});
app.get('/login', (req, res) => {
const state = generators.state();
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
req.session.oidc = { state, codeVerifier };
const url = client.authorizationUrl({
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
res.redirect(url);
});
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req);
const saved = req.session.oidc;
if (!saved) return res.status(400).send('missing session');
const tokenSet = await client.callback(
process.env.OIDC_REDIRECT_URI,
params,
{ state: saved.state, code_verifier: saved.codeVerifier }
);
res.json({ ok: true, claims: tokenSet.claims() });
});
여기서도 핵심은 동일합니다. 콜백에서 req.session.oidc가 없으면 99퍼센트는 쿠키/세션/프록시 문제입니다.
운영에서 재발 방지: "증상"이 아니라 "불변 조건"을 고정
state mismatch는 한 번 고쳐도 인프라 변경(도메인, 프록시, 스케일아웃)으로 재발합니다. 아래 불변 조건을 문서화해두면 재발률이 확 떨어집니다.
redirect_uri는 운영에서 고정 문자열(환경변수)로 관리- 세션 저장소는 Redis 등 공유 저장소 사용
- 프록시 뒤면
trust proxy와X-Forwarded-Proto전달을 보장 - 쿠키 정책은
SameSite=Lax기본, 필요한 경우에만None으로 전환 - 멀티 탭/연타를 고려해
state저장을 단일 값이 아닌 컬렉션으로 설계
결론
Node.js OAuth 콜백의 400 state mismatch는 대부분 "콜백 요청에서 세션을 복원하지 못했다"는 신호입니다. 따라서 해결은 state 문자열 자체를 뜯어고치는 게 아니라, 다음을 순서대로 확인하는 게 정석입니다.
- 콜백에 쿠키가 붙는가(브라우저 개발자 도구, 서버 로그)
- 프록시/HTTPS 인식이 올바른가(
trust proxy,X-Forwarded-Proto) - 세션 저장소가 멀티 인스턴스에서 공유되는가(Redis 등)
- 도메인/프로토콜/포트가 로그인 시작과 콜백에서 동일한가
- 동시 로그인 시도에
state저장이 덮어써지지 않는가
이 5가지를 고정하면, 라이브러리가 무엇이든 state mismatch는 대부분 사라집니다.