Published on

OAuth 2.0 PKCE에서 state 불일치 원인 7가지

Authors

OAuth 2.0 Authorization Code + PKCE를 붙이면 모바일·SPA·공개 클라이언트에서도 안전하게 코드 교환을 할 수 있습니다. 그런데 운영에서 은근히 많이 터지는 문제가 state mismatch 입니다. 로그인은 리다이렉트까지는 잘 되는데, 콜백에서 state 가 다르다며 인증을 거부하거나 무한 리다이렉트에 빠지는 증상이죠.

state 는 CSRF 방어와 로그인 트랜잭션 상관관계(correlation)를 위해 요청 시점에 생성하고 콜백에서 검증하는 값입니다. PKCE의 code_verifier/code_challenge 와는 역할이 다르지만, 실제 구현에서는 둘 다 “요청 시점에 만들고 콜백에서 찾아야 하는 값”이라 같은 종류의 버그로 함께 깨지는 경우가 많습니다.

아래는 PKCE 환경에서 state 불일치가 발생하는 대표 원인 7가지와, 각각의 진단 포인트 및 해결책입니다.

빠른 전제: 올바른 state 저장·검증 패턴

가장 안전한 기본형은 다음 두 가지 중 하나입니다.

  1. 서버 사이드 세션(권장): statecode_verifier 를 서버 세션에 저장
  2. 브라우저 저장소: 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_urihttp 로 생성되거나 호스트가 내부 도메인으로 생성됨
  • 환경별로 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는 서버 세션 대신 sessionStoragestate 를 저장하는 구현을 많이 합니다. 그런데 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을 재방문

진단 포인트

  • 동일한 codestate 조합으로 콜백 요청이 연속해서 들어오는지
  • 개발 환경에서만 재현되는지(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가 뜨면 이것부터 본다

  1. 콜백 요청에 쿠키/세션이 붙었는지(Network에서 Cookie 확인)
  2. 로그인 시작 시점 state 와 콜백의 state 를 원문 그대로 로깅해 비교
  3. 멀티 탭/중복 클릭으로 state 가 덮였는지 확인(동시 authorize 요청)
  4. 콜백이 2회 이상 호출되는지(Strict Mode, 새로고침, 재시도)
  5. 서버가 여러 대라면 세션 공유가 되는지(로컬 메모리 금지)
  6. state 값 포맷이 base64url/hex인지(인코딩 안전)
  7. redirect_uri 가 프록시 뒤에서 정확히 생성되는지(X-Forwarded-*)

마무리: PKCE에서도 state는 “트랜잭션의 열쇠”다

PKCE가 코드 탈취를 막아주더라도, state 가 흔들리면 트랜잭션 자체가 성립하지 않습니다. 운영에서 안정적으로 만들려면 state 를 단일 문자열 비교로만 보지 말고, 동시성·저장소·쿠키 정책·콜백 멱등성까지 포함해 “로그인 트랜잭션”으로 설계해야 합니다.

특히 멀티 탭과 콜백 중복 호출은 재현이 어렵지만 가장 흔한 원인이므로, state 를 키로 분리 저장하고 TTL을 두며, 콜백을 멱등하게 만드는 것만으로도 실패율이 크게 줄어듭니다.