- Published on
OAuth PKCE 검증 실패 - code_verifier 불일치 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인(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))
- 방식
흐름은 아래처럼 “두 번” 등장합니다.
- 인가 요청(Authorization Request)
code_challenge,code_challenge_method=S256를 함께 보냄
- 토큰 요청(Token Request)
- 원문
code_verifier를 보냄
서버는 토큰 요청에서 받은 code_verifier 로 다시 code_challenge 를 계산해, 1)에서 저장해둔 값과 비교합니다. 여기서 1바이트라도 다르면 실패합니다.
증상 패턴: 어떤 에러가 뜨나
IdP(Authorization Server)마다 메시지는 다르지만 보통 아래 중 하나로 수렴합니다.
- HTTP
400+invalid_grant PKCE verification failedCode verifier mismatchInvalid 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 가 이전 시도의 인가 코드와 매칭되지 않습니다.
해결:
state와code_verifier를 1: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_challenge 는 Base64URL 인코딩입니다. +, /, = 가 섞인 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 문제처럼 보일 수 있습니다.
- 슬래시 유무, 쿼리 파라미터 순서,
httpvshttps확인 - 프록시/로드밸런서 뒤에서 외부 스킴이 바뀌는지 점검
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를 백엔드가 알아야 함 - 그런데 이를 쿠키/세션으로 안전하게 전달하지 않으면, 요청 간 매칭이 깨집니다.
권장 패턴:
- 프론트가
state와code_verifier를 생성 state기준으로 백엔드 세션에code_verifier를 저장하는 API 호출- 인가 요청 진행
- 콜백은 백엔드가 받고, 세션에서
code_verifier를 꺼내 토큰 교환
이때도 state 기반 매칭이 핵심입니다.
6단계: 운영에서 재발 방지(가드레일)
PKCE 불일치는 “한 번 고치면 끝”이 아니라, UI/라우팅/재시도/스토리지 정책이 바뀔 때 재발합니다. 다음 가드레일을 추천합니다.
- 로그인 버튼 연타 방지(요청 중 disable)
state별로 PKCE 저장(단일 슬롯 금지)- 콜백 처리 후
pkce:{state}즉시 삭제 verifier.length범위 체크(43~128)로 조기 실패- 에러 발생 시
state유실 여부를 먼저 알림(관측 가능성)
프론트에서 병목이나 이벤트 루프 지연으로 중복 요청이 생기는 경우도 있습니다. 사용자 입력과 네트워크 요청 타이밍을 다듬는 관점은 Chrome INP 폭증? Long Task를 50ms로 쪼개는 법 글이 간접적으로 도움이 됩니다.
결론: 불일치는 “암호”가 아니라 “상태” 문제다
code_verifier 불일치는 PKCE 수학이 어려워서가 아니라, 리다이렉트 전후로 상태를 안전하게 유지하고, 시도 단위로 분리하며, 인코딩을 정확히 적용하는지의 문제입니다.
진단 순서는 단순하게 가져가면 됩니다.
- 인가 요청 시점과 토큰 요청 시점의
challenge를 각각 계산해 동일한지 확인 - 동일하지 않다면 저장소/탭 경합/SSR 경계/인코딩을 체크리스트로 소거
- 동일한데도 실패한다면 redirect URI 동일성, 코드 재사용, IdP 설정을 점검
이 과정을 한 번 템플릿으로 만들어두면, IdP가 바뀌거나 앱 구조가 바뀌어도 같은 방식으로 빠르게 원인을 좁힐 수 있습니다.