Published on

OAuth PKCE 검증 실패 - code_verifier 불일치 진단

Authors

서드파티 로그인(OAuth 2.0 Authorization Code)에서 PKCE를 붙이면 보안은 좋아지지만, 실무에서 가장 흔한 장애는 토큰 엔드포인트에서 invalid_grant 혹은 PKCE verification failed 류의 에러가 터지는 경우입니다. 대부분 원인은 단순합니다. 인가 요청 때 만든 code_verifier 와 토큰 요청 때 보내는 code_verifier 가 같지 않다는 뜻입니다.

문제는 “왜 달라졌는지”가 프론트/백/인프라/브라우저 상태에 걸쳐 숨어 있다는 점입니다. 이 글은 불일치가 발생하는 전형적인 지점을 재현 가능한 형태로 쪼개서 진단하는 방법을 다룹니다.

PKCE 동작을 최소 단위로 다시 보기

PKCE는 두 값만 정확히 맞으면 됩니다.

  • code_verifier: 클라이언트가 생성하는 고엔트로피 문자열(43~128 chars)
  • code_challenge: code_verifier 를 변환한 값
    • 방식 S256 권장: BASE64URL(SHA256(code_verifier))

흐름은 아래처럼 “두 번” 등장합니다.

  1. 인가 요청(Authorization Request)
  • code_challenge, code_challenge_method=S256 를 함께 보냄
  1. 토큰 요청(Token Request)
  • 원문 code_verifier 를 보냄

서버는 토큰 요청에서 받은 code_verifier 로 다시 code_challenge 를 계산해, 1)에서 저장해둔 값과 비교합니다. 여기서 1바이트라도 다르면 실패합니다.

증상 패턴: 어떤 에러가 뜨나

IdP(Authorization Server)마다 메시지는 다르지만 보통 아래 중 하나로 수렴합니다.

  • HTTP 400 + invalid_grant
  • PKCE verification failed
  • Code verifier mismatch
  • Invalid code_verifier

중요한 점: 이 에러는 종종 “인가 코드가 잘못됨”과 동일한 invalid_grant 로 뭉개져 나옵니다. 따라서 인가 코드 만료/재사용PKCE 불일치를 분리 진단해야 합니다.

1단계: “같은 값”인지 확인하는 로깅 전략

가장 먼저 할 일은 감이 아니라 증거를 남기는 것입니다. 단, code_verifier 는 민감정보이므로 그대로 로그에 남기면 안 됩니다.

권장 방식은 다음 두 가지입니다.

  • code_verifier 의 길이와 해시만 로깅
  • 토큰 요청 직전에 계산한 code_challenge 를 로깅하고, 인가 요청 때 보낸 code_challenge 와 비교

예: 브라우저(또는 Node)에서 안전한 디버그 로그

// debug-pkce.ts
async function sha256Base64Url(input: string) {
  const data = new TextEncoder().encode(input);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const bytes = new Uint8Array(digest);
  const b64 = btoa(String.fromCharCode(...bytes));
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

async function debugPkce(verifier: string) {
  const challenge = await sha256Base64Url(verifier);

  // 민감정보 직접 출력 금지: 길이와 앞뒤 일부만
  console.log('[pkce] verifier.length=', verifier.length);
  console.log('[pkce] verifier.preview=', verifier.slice(0, 4) + '...' + verifier.slice(-4));
  console.log('[pkce] challenge=', challenge);
}

이제 인가 요청을 만들 때와 토큰 요청을 만들 때 각각 debugPkce 를 호출해 challenge 가 동일하게 찍히는지 확인합니다. 동일하지 않다면 100% 클라이언트 측 저장/인코딩/전송 문제입니다.

2단계: 가장 흔한 원인 10가지(체크리스트)

아래는 현장에서 실제로 많이 터지는 순서대로 정리한 원인들입니다.

1) code_verifier 를 요청마다 새로 생성함

인가 요청을 보낸 뒤 리다이렉트로 돌아오는 동안 앱이 재시작되거나, 라우팅이 바뀌면서 code_verifier 를 다시 만들면 불일치가 납니다.

  • 인가 요청 때 만든 code_verifier리다이렉트 이후까지 유지해야 합니다.
  • SPA라면 sessionStorage 를 우선 고려합니다(탭 단위 격리).
// pkce-storage.ts
const KEY = 'pkce_verifier';

export function saveVerifier(v: string) {
  sessionStorage.setItem(KEY, v);
}

export function loadVerifier(): string {
  const v = sessionStorage.getItem(KEY);
  if (!v) throw new Error('missing pkce verifier in sessionStorage');
  return v;
}

export function clearVerifier() {
  sessionStorage.removeItem(KEY);
}

2) 탭/창 경합: 다른 로그인 시도에 의해 값이 덮어써짐

사용자가 로그인 버튼을 연속 클릭하거나, 두 탭에서 동시에 로그인하면 마지막에 저장된 code_verifier 가 이전 시도의 인가 코드와 매칭되지 않습니다.

해결:

  • statecode_verifier1:1로 묶어서 저장
  • 저장 키를 pkce:{state} 형태로 분리
export function savePkceForState(state: string, verifier: string) {
  sessionStorage.setItem(`pkce:${state}`, verifier);
}

export function loadPkceForState(state: string) {
  const v = sessionStorage.getItem(`pkce:${state}`);
  if (!v) throw new Error('missing pkce verifier for state');
  return v;
}

3) state 검증은 하는데, code_verifier 는 state와 무관하게 단일 슬롯에 저장

위 2)와 같은 문제를 더 자주 만듭니다. state 를 검증한다면 저장도 state 기준으로 해야 논리적으로 닫힙니다.

4) Base64URL이 아니라 Base64를 사용함

PKCE의 code_challengeBase64URL 인코딩입니다. +, /, = 가 섞인 Base64를 그대로 보내면 서버가 다르게 해석하거나 거부합니다.

  • + -
  • / _
  • trailing = 제거

위의 sha256Base64Url 예제처럼 치환/트리밍을 반드시 적용하세요.

5) code_verifier 를 URL 인코딩/디코딩하는 과정에서 값이 변형됨

code_verifier 는 토큰 요청 바디에 들어갑니다. 보통 application/x-www-form-urlencoded 로 보내는데, 이때 라이브러리가 알아서 인코딩합니다.

문제는 개발자가 중복으로 encodeURIComponent 를 적용하거나, 반대로 디코딩을 한 번 더 해버리는 경우입니다.

  • 폼 인코딩은 HTTP 클라이언트에 맡기고 원문 문자열을 넘기세요.
// 올바른 예: 원문 verifier를 그대로 넣는다
const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: clientId,
  code,
  redirect_uri: redirectUri,
  code_verifier: verifier,
});

await fetch(tokenEndpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body,
});

6) 줄바꿈/공백이 섞임(복사·붙여넣기, env 주입, JSON 직렬화)

특히 서버에서 code_verifier 를 임시 저장할 때, 줄바꿈이 들어가거나 트림이 일어나면 바로 실패합니다.

  • 저장 시 trim() 을 섣불리 적용하지 마세요(원문 보존)
  • 디버그 시 verifier.length 로 이상 징후를 확인하세요

7) 리다이렉트 URI가 미세하게 달라 토큰 요청이 다른 트랜잭션으로 처리됨

일부 IdP는 인가 요청의 redirect_uri 와 토큰 요청의 redirect_uri 가 바이트 단위로 동일해야 합니다. 다르면 invalid_grant 로 떨어져 PKCE 문제처럼 보일 수 있습니다.

  • 슬래시 유무, 쿼리 파라미터 순서, http vs https 확인
  • 프록시/로드밸런서 뒤에서 외부 스킴이 바뀌는지 점검

8) 인가 코드를 두 번 교환함(재시도/중복 요청)

네트워크 재시도 로직이나 더블 클릭으로 토큰 교환이 2번 발생하면, 두 번째는 invalid_grant 입니다.

  • 토큰 교환 요청에 멱등성을 부여하기 어렵기 때문에, UI에서 버튼 비활성화 및 요청 중복 방지 필요
  • 서버에서 교환 결과를 캐시하거나 1회성 락을 고려

재시도/중복 호출을 제어하는 관점은 “장애 원인 추적과 패턴화”가 중요합니다. 비슷한 접근으로 문제를 좁히는 방법은 MySQL 8 Deadlock 1213 원인추적·재시도 패턴 글의 진단 프레임도 참고할 만합니다.

9) 모바일/데스크톱에서 외부 브라우저로 전환되며 저장소가 끊김

네이티브 앱(WebView)에서 시스템 브라우저로 인증 후 앱으로 돌아오는 경우, WebView의 sessionStorage 가 유지되지 않을 수 있습니다.

  • 네이티브라면 OS 안전 저장소(Keychain/Keystore)에 state 기반으로 저장
  • 웹이라면 같은 탭 유지가 보장되는 플로우인지 확인

10) 서버 사이드 렌더링(SSR)에서 crypto/저장소 사용 위치가 꼬임

Next.js 같은 환경에서 PKCE 생성이 서버에서 일어나면, 리다이렉트 이후 브라우저에서 토큰 교환할 때 verifier를 잃습니다.

  • PKCE 생성과 저장은 브라우저에서만 수행
  • 혹은 백엔드가 전 과정을 담당(Backend for Frontend 패턴)하여 verifier를 서버 세션에 저장

성능/렌더링 이슈로 SSR 경계가 흔들릴 때 원인을 좁히는 방법은 Next.js 14 App Router TTFB 폭증 잡는 RSC 튜닝 처럼 “어디서 실행되는 코드인가”를 분해하는 접근이 도움이 됩니다.

3단계: 재현을 위한 “정상 PKCE” 레퍼런스 구현

구현이 여러 라이브러리에 흩어져 있으면 디버깅이 어렵습니다. 아래는 최소 구현(브라우저 기준)입니다.

// pkce.ts
function randomUrlSafeString(byteLength = 32) {
  const bytes = new Uint8Array(byteLength);
  crypto.getRandomValues(bytes);
  const b64 = btoa(String.fromCharCode(...bytes));
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}

export async function createPkce() {
  // RFC 7636: verifier length 43~128
  const verifier = randomUrlSafeString(64);

  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const bytes = new Uint8Array(digest);
  const b64 = btoa(String.fromCharCode(...bytes));
  const challenge = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

  return { verifier, challenge, method: 'S256' as const };
}

인가 요청 만들기:

import { createPkce } from './pkce';

export async function startLogin() {
  const state = crypto.randomUUID();
  const { verifier, challenge, method } = await createPkce();

  sessionStorage.setItem(`pkce:${state}`, verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid profile email',
    state,
    code_challenge: challenge,
    code_challenge_method: method,
  });

  location.href = `https://idp.example.com/authorize?${params.toString()}`;
}

콜백에서 토큰 교환:

export async function handleCallback() {
  const url = new URL(location.href);
  const code = url.searchParams.get('code');
  const state = url.searchParams.get('state');

  if (!code || !state) throw new Error('missing code/state');

  const verifier = sessionStorage.getItem(`pkce:${state}`);
  if (!verifier) throw new Error('missing verifier for returned state');

  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://app.example.com/callback',
    code,
    code_verifier: verifier,
  });

  const res = await fetch('https://idp.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body,
  });

  if (!res.ok) {
    const text = await res.text();
    throw new Error(`token exchange failed: ${res.status} ${text}`);
  }

  sessionStorage.removeItem(`pkce:${state}`);
  return res.json();
}

이 레퍼런스 구현으로도 실패한다면, 클라이언트 구현 문제가 아니라 다음을 의심해야 합니다.

  • IdP 설정(허용된 redirect URI, PKCE 필수/옵션)
  • 클라이언트 타입 설정(공개 클라이언트 vs 기밀 클라이언트)
  • 프록시가 토큰 요청을 변조하거나 캐시하는지

4단계: 네트워크 캡처로 “바이트 단위” 비교하기

브라우저 DevTools의 Network 탭에서 확인할 포인트:

  • /authorize 요청의 쿼리에 code_challenge 값이 있는지
  • /token 요청 바디에 code_verifier 가 있는지
  • code_verifier 가 비어 있거나 길이가 비정상적으로 짧지 않은지

여기서도 주의할 점이 있습니다.

  • 일부 도구는 긴 폼 바디를 축약 표시합니다. 복사 기능으로 원문을 확인하세요.
  • 프록시(예: 사내 보안 프록시)나 브라우저 확장 프로그램이 요청을 바꿀 가능성도 배제하지 마세요.

5단계: 프론트-백 분리 아키텍처에서의 함정

SPA가 인가 요청을 만들고, 백엔드가 토큰 교환을 하는 구조에서 PKCE 불일치가 특히 자주 납니다.

  • 프론트에서 만든 code_verifier 를 백엔드가 알아야 함
  • 그런데 이를 쿠키/세션으로 안전하게 전달하지 않으면, 요청 간 매칭이 깨집니다.

권장 패턴:

  1. 프론트가 statecode_verifier 를 생성
  2. state 기준으로 백엔드 세션에 code_verifier 를 저장하는 API 호출
  3. 인가 요청 진행
  4. 콜백은 백엔드가 받고, 세션에서 code_verifier 를 꺼내 토큰 교환

이때도 state 기반 매칭이 핵심입니다.

6단계: 운영에서 재발 방지(가드레일)

PKCE 불일치는 “한 번 고치면 끝”이 아니라, UI/라우팅/재시도/스토리지 정책이 바뀔 때 재발합니다. 다음 가드레일을 추천합니다.

  • 로그인 버튼 연타 방지(요청 중 disable)
  • state 별로 PKCE 저장(단일 슬롯 금지)
  • 콜백 처리 후 pkce:{state} 즉시 삭제
  • verifier.length 범위 체크(43~128)로 조기 실패
  • 에러 발생 시 state 유실 여부를 먼저 알림(관측 가능성)

프론트에서 병목이나 이벤트 루프 지연으로 중복 요청이 생기는 경우도 있습니다. 사용자 입력과 네트워크 요청 타이밍을 다듬는 관점은 Chrome INP 폭증? Long Task를 50ms로 쪼개는 법 글이 간접적으로 도움이 됩니다.

결론: 불일치는 “암호”가 아니라 “상태” 문제다

code_verifier 불일치는 PKCE 수학이 어려워서가 아니라, 리다이렉트 전후로 상태를 안전하게 유지하고, 시도 단위로 분리하며, 인코딩을 정확히 적용하는지의 문제입니다.

진단 순서는 단순하게 가져가면 됩니다.

  1. 인가 요청 시점과 토큰 요청 시점의 challenge 를 각각 계산해 동일한지 확인
  2. 동일하지 않다면 저장소/탭 경합/SSR 경계/인코딩을 체크리스트로 소거
  3. 동일한데도 실패한다면 redirect URI 동일성, 코드 재사용, IdP 설정을 점검

이 과정을 한 번 템플릿으로 만들어두면, IdP가 바뀌거나 앱 구조가 바뀌어도 같은 방식으로 빠르게 원인을 좁힐 수 있습니다.