- Published on
OAuth 2.0 PKCE에서 state 불일치 원인 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
OAuth 2.0 Authorization Code + PKCE를 붙이면 모바일·SPA·공개 클라이언트에서도 안전하게 코드 교환을 할 수 있습니다. 그런데 운영에서 은근히 많이 터지는 문제가 state mismatch 입니다. 로그인은 리다이렉트까지는 잘 되는데, 콜백에서 state 가 다르다며 인증을 거부하거나 무한 리다이렉트에 빠지는 증상이죠.
state 는 CSRF 방어와 로그인 트랜잭션 상관관계(correlation)를 위해 요청 시점에 생성하고 콜백에서 검증하는 값입니다. PKCE의 code_verifier/code_challenge 와는 역할이 다르지만, 실제 구현에서는 둘 다 “요청 시점에 만들고 콜백에서 찾아야 하는 값”이라 같은 종류의 버그로 함께 깨지는 경우가 많습니다.
아래는 PKCE 환경에서 state 불일치가 발생하는 대표 원인 7가지와, 각각의 진단 포인트 및 해결책입니다.
빠른 전제: 올바른 state 저장·검증 패턴
가장 안전한 기본형은 다음 두 가지 중 하나입니다.
- 서버 사이드 세션(권장):
state와code_verifier를 서버 세션에 저장 - 브라우저 저장소: SPA에서
sessionStorage등에 저장하되 탭/리다이렉트/도메인 이슈를 고려
아래는 Next.js(서버)에서 세션 쿠키에 저장하는 단순 예시입니다. 핵심은 state 를 “발급한 사용자 에이전트”와 강하게 묶는 것입니다.
// pseudo: Next.js Route Handler
import { cookies } from "next/headers";
import crypto from "crypto";
function base64url(buf: Buffer) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export async function GET() {
const state = base64url(crypto.randomBytes(16));
const codeVerifier = base64url(crypto.randomBytes(32));
// 세션 스토리지 대신 예시로 쿠키 사용(실서비스는 암호화/서명 권장)
cookies().set("oauth_state", state, {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 10,
});
cookies().set("pkce_verifier", codeVerifier, {
httpOnly: true,
sameSite: "lax",
secure: true,
path: "/",
maxAge: 60 * 10,
});
const authorizeUrl = new URL("https://idp.example.com/oauth/authorize");
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", process.env.CLIENT_ID!);
authorizeUrl.searchParams.set("redirect_uri", "https://app.example.com/oauth/callback");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
authorizeUrl.searchParams.set("code_challenge", "...derived_from_verifier...");
return Response.redirect(authorizeUrl.toString());
}
콜백에서는 query.state 와 저장된 oauth_state 를 비교하고, 1회성으로 소비(삭제)합니다.
import { cookies } from "next/headers";
export async function GET(req: Request) {
const url = new URL(req.url);
const returnedState = url.searchParams.get("state");
const storedState = cookies().get("oauth_state")?.value;
if (!returnedState || !storedState || returnedState !== storedState) {
return new Response("state mismatch", { status: 401 });
}
// 1회성 소비
cookies().delete("oauth_state");
// 이후 code 교환...
return new Response("ok");
}
이제부터는 이 기본형이 왜 깨지는지, 실무에서 자주 만나는 7가지 원인을 봅니다.
원인 1) 멀티 탭·중복 로그인 시도로 state가 덮어쓰기됨
가장 흔합니다. 사용자가 로그인 버튼을 두 번 누르거나, 탭 두 개에서 동시에 로그인하면 state 는 “마지막 요청 값”으로 저장됩니다. 먼저 시작한 플로우가 나중에 콜백으로 돌아오면 저장된 state 와 달라져 mismatch가 납니다.
진단 포인트
oauth_state를 단일 키로만 저장하고 있지 않은지 확인- 콜백 로그에서 “동일 사용자/동일 브라우저”에서 거의 동시에 여러
authorize요청이 발생했는지 확인
해결책
state를 키로 하는 맵 구조로 저장하고, TTL을 둔 뒤 콜백에서 해당state를 조회- 또는
state에 “트랜잭션 ID”를 넣고, 저장소를oauth_state:{state}형태로 분리
예시(서버 메모리 대신 Redis 같은 KV에 저장하는 형태):
// pseudo
const key = `oauth_state:${state}`;
await kv.set(key, JSON.stringify({ createdAt: Date.now(), codeVerifier }), { ttl: 600 });
// callback
const payload = await kv.get(`oauth_state:${returnedState}`);
if (!payload) throw new Error("state mismatch");
await kv.del(`oauth_state:${returnedState}`);
원인 2) SameSite/도메인/경로 설정으로 쿠키가 콜백에 안 붙음
서버 세션 쿠키로 state 를 저장했다면, 콜백 요청에 쿠키가 포함되지 않으면 저장된 state 를 읽을 수 없어 mismatch가 납니다.
특히 다음 조합에서 자주 터집니다.
app.example.com에서 로그인 시작, 콜백이example.com또는auth.example.com으로 들어옴- 쿠키
Domain/Path가 콜백 경로를 커버하지 않음 SameSite=strict로 설정해 외부 IdP에서 돌아오는 리다이렉트에 쿠키가 제외됨
진단 포인트
- 브라우저 DevTools의 Network에서 콜백 요청
Cookie헤더 확인 - Set-Cookie 속성(
Domain,Path,SameSite,Secure) 확인
해결책
- 동일한 호스트에서 시작과 콜백을 처리
SameSite=lax를 기본으로 고려(대부분의 OAuth 리다이렉트는 top-level navigation이라lax로 통과)- HTTPS에서
Secure=true필수
프론트 성능 이슈로 로그인 버튼을 여러 번 눌러 멀티 플로우가 생기기도 합니다. UI가 멈칫하면서 중복 클릭이 발생한다면, 입력 지연을 먼저 줄이는 것도 간접적인 해결이 됩니다. 관련해서는 Chrome INP 폭증? Long Task 추적·분해 실전도 참고할 만합니다.
원인 3) 프록시/로드밸런서 뒤에서 redirect_uri가 환경마다 달라짐
state 자체는 쿼리로 돌아오지만, “로그인 시작 요청을 처리한 서버”와 “콜백을 처리한 서버”가 세션을 공유하지 않으면 저장된 state 를 찾지 못합니다.
대표 시나리오:
- 쿠키 기반 세션인데, 서버가 여러 대이고 세션 스토리지가 로컬 메모리
X-Forwarded-Proto처리가 잘못되어redirect_uri가http로 생성되거나 호스트가 내부 도메인으로 생성됨- 환경별로
redirect_uri가 달라져 IdP에서 다른 앱으로 리다이렉트됨
진단 포인트
- 콜백을 처리한 인스턴스가 로그인 시작을 처리한 인스턴스와 동일한지(로그에 인스턴스 ID 포함)
redirect_uri를 실제로 무엇으로 생성했는지(로그로 남기기)
해결책
- 세션을 Redis 같은 중앙 저장소로 공유하거나, stateless하게
state자체를 서명 토큰으로 만들기 - 프록시 헤더 신뢰 설정을 올바르게 구성
서명 토큰 예시(개념):
// pseudo: HMAC으로 state를 서명해 서버 저장 없이 검증
const payload = JSON.stringify({ nonce, ts: Date.now(), returnTo: "/" });
const sig = hmacSha256(secret, payload);
const state = base64url(Buffer.from(payload)) + "." + base64url(sig);
// callback: payload와 sig를 분리해 재계산 후 비교
원인 4) state를 URL 인코딩/디코딩 과정에서 변형
state 는 URL 쿼리로 오가기 때문에, 인코딩이 어긋나면 값이 달라질 수 있습니다.
자주 보이는 케이스:
+가 공백으로 바뀜(특히application/x-www-form-urlencoded파서에서)- base64를 그대로 쓰다가
/,=등이 섞여 인코딩 과정에서 변형 - 미들웨어가 쿼리 파라미터를 재정렬/정규화하면서 이스케이프가 달라짐
진단 포인트
- 로그인 시작 시점의
state원문과, 콜백에서 받은state원문을 그대로 로그로 비교 - 쿼리 파서가 어떤 라이브러리인지 확인
해결책
state는 base64url 또는 hex로 제한(예:crypto.randomBytes(16).toString("hex"))- 직접 문자열 조합 대신
URL/URLSearchParams를 사용해 인코딩을 일관되게 유지
원인 5) SPA에서 sessionStorage/localStorage 범위가 달라짐
SPA는 서버 세션 대신 sessionStorage 에 state 를 저장하는 구현을 많이 합니다. 그런데 sessionStorage 는 “탭 단위”라서, 로그인 과정에서 새 탭이 열리거나(또는 인앱 브라우저/외부 브라우저 전환) 콜백이 다른 컨텍스트로 들어오면 저장된 값이 없습니다.
또한 iOS Safari, 인앱 브라우저(WebView)에서는 저장소가 예기치 않게 초기화되거나, 서드파티 쿠키 정책과 섞여 플로우가 불안정해질 수 있습니다.
진단 포인트
- 콜백 페이지에서
sessionStorage.getItem("oauth_state")가null인지 확인 - “외부 브라우저로 전환”이 일어나는지(예: 카카오톡/인스타 인앱 브라우저)
해결책
- 가능하면 서버 세션으로 옮기기
- SPA만으로 해야 한다면
state를 쿠키(1st-party)로 저장하고 콜백에서 읽기 - 또는 콜백을 SPA 라우트가 아닌 서버 엔드포인트로 받아서 교환까지 서버에서 처리
원인 6) 콜백 라우트가 중복 호출되며 1회성 state가 먼저 소비됨
보안적으로 state 는 1회성으로 소비하는 게 맞습니다. 하지만 콜백 라우트가 두 번 호출되면 두 번째 요청은 mismatch가 납니다.
중복 호출이 생기는 패턴:
- 콜백 페이지에서
useEffect로 토큰 교환 API를 호출하는데, React Strict Mode 개발 환경에서 이펙트가 2회 실행 - 네트워크 재시도(브라우저/프록시/서비스워커)로 동일 콜백이 재전송
- 사용자가 뒤로 가기/새로고침을 눌러 콜백 URL을 재방문
진단 포인트
- 동일한
code와state조합으로 콜백 요청이 연속해서 들어오는지 - 개발 환경에서만 재현되는지(Strict Mode)
해결책
- 콜백 처리 로직을 “서버에서 단발로 끝내고” 프론트는 결과 페이지로만 이동
- 콜백 엔드포인트에 idempotency를 부여(예:
state키로 이미 처리됐으면 성공 페이지로 리다이렉트)
메시지 멱등성 개념은 OAuth에도 그대로 적용됩니다. 이벤트 중복 처리 관점에서 정리한 글로 Kafka 중복·역순 메시지, DDD로 멱등 처리하기가 참고가 됩니다.
간단한 멱등 처리 예시:
// pseudo
const doneKey = `oauth_done:${returnedState}`;
if (await kv.get(doneKey)) {
return Response.redirect("/login/success");
}
// ...state 검증 및 토큰 교환 성공 후
await kv.set(doneKey, "1", { ttl: 300 });
원인 7) state 생성/검증 로직이 환경별로 달라짐(서버리스, 엣지, 빌드 설정)
서버리스/엣지 런타임에서는 다음과 같은 이유로 “저장했다고 믿었는데 사실은 저장이 안 되는” 상황이 생깁니다.
- 인메모리 저장소 사용(요청 간 유지되지 않음)
- 지역(Region) 분산으로 로그인 시작과 콜백이 다른 리전에 떨어짐
- 런타임별 API 차이로 쿠키/헤더 처리 방식이 달라짐
또한 CI에서 환경 변수가 달라 redirect_uri/cookie domain 이 바뀌는 식의 배포 이슈도 있습니다.
진단 포인트
- 런타임이
nodejs인지edge인지 확인 state저장소가 요청 간 유지되는지(서버리스에서 메모리 저장은 대부분 불가)- 배포 전후로
redirect_uri/쿠키 속성이 바뀌었는지
해결책
state/code_verifier는 Redis, DynamoDB 같은 외부 저장소 또는 서명 토큰으로 처리- 멀티 리전이면 저장소도 전역 접근 가능한 것으로 선택하거나, 콜백을 동일 리전으로 라우팅
- CI에서 OAuth 관련 환경 변수를 검증하는 스모크 테스트 추가
배포 파이프라인에서 클라우드 자격 증명 없이도 안전하게 검증을 자동화하고 싶다면 GitHub Actions OIDC로 AWS 키 없이 배포하기가 도움이 됩니다.
실전 체크리스트: state mismatch가 뜨면 이것부터 본다
- 콜백 요청에 쿠키/세션이 붙었는지(Network에서
Cookie확인) - 로그인 시작 시점
state와 콜백의state를 원문 그대로 로깅해 비교 - 멀티 탭/중복 클릭으로
state가 덮였는지 확인(동시 authorize 요청) - 콜백이 2회 이상 호출되는지(Strict Mode, 새로고침, 재시도)
- 서버가 여러 대라면 세션 공유가 되는지(로컬 메모리 금지)
state값 포맷이 base64url/hex인지(인코딩 안전)redirect_uri가 프록시 뒤에서 정확히 생성되는지(X-Forwarded-*)
마무리: PKCE에서도 state는 “트랜잭션의 열쇠”다
PKCE가 코드 탈취를 막아주더라도, state 가 흔들리면 트랜잭션 자체가 성립하지 않습니다. 운영에서 안정적으로 만들려면 state 를 단일 문자열 비교로만 보지 말고, 동시성·저장소·쿠키 정책·콜백 멱등성까지 포함해 “로그인 트랜잭션”으로 설계해야 합니다.
특히 멀티 탭과 콜백 중복 호출은 재현이 어렵지만 가장 흔한 원인이므로, state 를 키로 분리 저장하고 TTL을 두며, 콜백을 멱등하게 만드는 것만으로도 실패율이 크게 줄어듭니다.