- Published on
OAuth PKCE code_verifier 불일치 5가지 원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 로그인에서 invalid_grant 혹은 PKCE verification failed 류의 에러를 만나면, 원인의 상당수는 code_verifier 가 "다르게" 들어갔기 때문입니다. PKCE는 개념은 단순하지만, 실제 구현은 브라우저 리다이렉트, 앱 재시작, 멀티탭, 프록시, 인코딩 규칙 등 변수들이 많아 불일치가 쉽게 발생합니다.
이 글은 PKCE 흐름을 아주 짧게 짚고, 운영에서 가장 흔한 code_verifier 불일치 5가지 원인을 재현 가능한 관점으로 분해합니다. 각 원인별로 "증상", "왜 발생하는지", "어떻게 확인하는지", "어떻게 고치는지"를 코드와 함께 정리합니다.
참고: PKCE는 Authorization Code Flow에서
code_challenge를 인가 요청에 포함하고, 토큰 교환 시code_verifier를 제출해 동일성을 검증합니다. 불일치하면 보통 토큰 엔드포인트에서invalid_grant로 실패합니다.
PKCE 핵심 규칙(불일치 디버깅의 기준선)
1) code_verifier 형식
- 길이: 43~128
- 문자 집합: unreserved(
A-Z a-z 0-9 - . _ ~)
2) code_challenge 계산(S256 권장)
code_challenge = BASE64URL( SHA256( ASCII(code_verifier) ) )BASE64URL은+를-로,/를_로 바꾸고,=패딩을 제거합니다.
아래 Node.js 예시는 계산 규칙을 그대로 보여줍니다.
import crypto from 'crypto';
function base64url(buf) {
return buf.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createVerifier() {
// 32바이트면 base64url로 43자 근처가 나옵니다.
return base64url(crypto.randomBytes(32));
}
export function createChallengeS256(verifier) {
const hash = crypto.createHash('sha256').update(verifier, 'ascii').digest();
return base64url(hash);
}
이제부터는 이 기준선에서 어디가 깨지는지 찾는 이야기입니다.
원인 1) code_verifier 저장 위치/수명 문제(세션, 쿠키, 스토리지)
대표 증상
- 로컬에서는 되는데 운영에서 간헐적으로 실패
- 특정 브라우저(사파리, 인앱 브라우저)에서만 실패
- 리다이렉트 후 토큰 교환 시점에
code_verifier가 비어 있거나 다른 값
왜 발생하나
인가 요청과 토큰 교환은 "서로 다른 요청" 입니다. 따라서 code_verifier 를 어딘가에 저장해 두었다가 토큰 교환 시 꺼내야 합니다.
여기서 흔한 함정은 다음과 같습니다.
- 서버 세션을 메모리로만 유지했는데, 리다이렉트 후 다른 인스턴스로 라우팅됨(스티키 세션 없음)
- SameSite 쿠키 정책 때문에 리다이렉트 이후 쿠키가 안 붙음
- SPA에서
sessionStorage를 썼는데, 브라우저/웹뷰가 리다이렉트 과정에서 세션을 날림 - 앱이 백그라운드로 갔다가 프로세스가 죽으면서 임시 저장소가 초기화됨(모바일)
확인 방법
- 인가 요청 시점에 생성한
code_verifier를 안전한 방식으로 로그(해시) 남기고, 토큰 교환 시점에 동일 키로 조회되는지 확인합니다. - 로드밸런서 뒤라면 토큰 교환 요청이 어떤 인스턴스로 갔는지 확인합니다.
code_verifier 원문을 로그로 남기면 보안상 좋지 않습니다. 아래처럼 SHA-256 해시만 남겨도 "같은 값인지"는 판별 가능합니다.
import crypto from 'crypto';
export function fingerprint(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 12);
}
// 예: console.log('pkce.verifier.fp', fingerprint(verifier));
해결책
- 서버:
code_verifier를 서버측 세션(공유 저장소 Redis 등)에 저장하고,state와 1:1로 매핑합니다. - 프론트: 브라우저 환경이 불안정하면
sessionStorage단독 의존을 피하고, 서버가 verifier를 관리하도록 설계합니다. - 쿠키:
SameSite=None; Secure필요 여부와 도메인/서브도메인 경계를 점검합니다.
원인 2) 멀티탭/동시 로그인으로 state 와 code_verifier 매핑이 꼬임
대표 증상
- 같은 브라우저에서 탭을 두 개 열고 로그인 시도하면 한쪽만 실패
- 간헐적으로
invalid_grant가 뜨고 재시도하면 성공
왜 발생하나
가장 흔한 구현 실수는 code_verifier 를 "전역 키" 하나에 저장하는 것입니다.
예를 들어 SPA에서 pkce_verifier 라는 키로 localStorage 에 저장하면, 두 번째 로그인 시도가 첫 번째 값을 덮어씁니다. 그런데 첫 번째 탭은 여전히 첫 번째 code_challenge 로 인가 코드를 받아오므로, 토큰 교환 시 code_verifier 가 불일치합니다.
확인 방법
- 인가 요청을 보낼 때
state를 매번 새로 생성하는지 확인 - 저장소 키가
state기반인지 확인
해결책
state를 키로 하여code_verifier를 저장합니다.- 토큰 교환이 끝나면 해당
state의 verifier를 즉시 삭제합니다.
아래는 브라우저에서 state 별로 저장하는 예시입니다.
function pkceKey(state) {
return `pkce.verifier.${state}`;
}
export function saveVerifier(state, verifier) {
sessionStorage.setItem(pkceKey(state), verifier);
}
export function loadVerifier(state) {
return sessionStorage.getItem(pkceKey(state));
}
export function clearVerifier(state) {
sessionStorage.removeItem(pkceKey(state));
}
동시성 이슈는 분산 시스템에서 멱등성/중복 처리와 닮아 있습니다. 로그인 플로우도 "한 번 시작한 요청" 을 식별하고 끝까지 같은 컨텍스트로 묶어야 합니다. 이런 관점은 결제 중복 방지에서 멱등키를 다루는 방식과 유사합니다: Saga+Outbox로 중복결제 막는 멱등키·Kafka 패턴
원인 3) Base64URL 인코딩 실수(패딩, 문자 치환, 이중 인코딩)
대표 증상
- 특정 라이브러리 조합에서만 실패
code_challenge를 직접 계산해 보면 스펙과 다름- 서버가
code_verifier를 받긴 하는데 검증이 실패
왜 발생하나
PKCE의 실패는 종종 "해시" 문제가 아니라 "인코딩" 문제입니다.
자주 나오는 실수:
- Base64를 쓰고
+/=처리를 안 함 - URL 인코딩을 잘못 적용해서
-_가 변형되거나,code_verifier자체를encodeURIComponent로 변형 code_challenge를 만들 때 입력 문자열 인코딩을utf8이 아닌 다른 것으로 처리
특히 code_verifier 는 원문 그대로 토큰 요청에 들어가야 합니다. 중간에 공백 변환, 줄바꿈, URL 디코딩/인코딩이 끼면 그대로 불일치가 납니다.
확인 방법
- 인가 요청에 실린
code_challenge를 캡처하고, 동일code_verifier로 로컬에서 재계산해 비교합니다. - 라이브러리가
S256을 정확히 구현하는지 확인합니다.
해결책
- 검증된 PKCE 라이브러리를 사용하거나, 위에서 제시한
base64url처리를 명시적으로 구현합니다. code_verifier를 쿼리스트링으로 직접 전달하는 구조를 피합니다(로그/리퍼러 노출 위험도 있음). 반드시 서버 세션이나 안전한 저장소에 보관하세요.
원인 4) 리다이렉트/프록시/미들웨어가 파라미터를 변형하거나 누락
대표 증상
- 특정 환경(프록시, WAF, CDN)에서만 실패
state가 누락되거나 다른 값으로 들어옴- 콜백 URL에 붙은 쿼리 파라미터가 일부 사라짐
왜 발생하나
인가 서버는 콜백 URL에 code 와 state 를 붙여 리다이렉트합니다. 그런데 중간에 있는 구성 요소가 다음을 일으킬 수 있습니다.
- 리라이트/리다이렉트 규칙이 쿼리를 보존하지 않음
- 프록시가 특정 파라미터를 보안 정책으로 제거
- 앱 라우터가 콜백을 처리하는 과정에서
state를 읽기 전에 다른 라우팅으로 넘겨버림
state 가 깨지면 결국 state 기반으로 저장했던 code_verifier 를 못 찾고, 다른 verifier를 쓰거나 빈 값으로 요청하게 됩니다.
확인 방법
- 브라우저 네트워크 탭에서 최종 콜백 URL의 원문을 확인
- 서버 액세스 로그에서 콜백 요청의 쿼리스트링이 원형대로 들어오는지 확인
- Next.js나 프레임워크 라우팅에서 콜백 핸들러가 정확히 매칭되는지 확인
해결책
- 리버스 프록시 설정에서 쿼리 보존을 명시
- 콜백 엔드포인트는 가능한 한 단순하게(리라이트 최소화)
state가 없으면 즉시 실패 처리하고, 진단 로그를 남깁니다
Nginx 같은 경계 계층 설정이 원인인 경우가 생각보다 많습니다. TLS/프록시 레이어에서의 예기치 않은 동작은 다른 보안 기능(mTLS 등)에서도 유사하게 나타납니다: Nginx mTLS 설정 후 495 SSL 오류 해결
원인 5) 토큰 교환을 "다른 클라이언트" 로 수행(클라이언트 불일치, 잘못된 앱 설정)
대표 증상
- 인가 코드는 발급되는데 토큰 교환에서만 실패
- 환경별로만 실패(개발은 OK, 운영은 실패)
- 같은 사용자/같은 브라우저인데 특정 배포 버전에서만 실패
왜 발생하나
PKCE 검증은 "인가 요청에 사용한 클라이언트" 와 "토큰 교환을 시도하는 클라이언트" 가 논리적으로 같은 흐름이어야 합니다.
다음 케이스가 흔합니다.
- 인가 요청은 모바일 앱의
client_id로 했는데, 토큰 교환은 웹의client_id로 함 - 운영/스테이징 설정이 섞여서 인가 요청은 A 환경, 토큰 교환은 B 환경으로 날아감
- Authorization Server가
redirect_uri일치까지 엄격히 보는데, 토큰 교환 시redirect_uri를 다르게 보내거나 누락
이 경우 에러 메시지가 code_verifier mismatch 로만 보이기도 해서, 진짜 원인이 설정 불일치인데 PKCE로 오해하기 쉽습니다.
확인 방법
- 인가 요청과 토큰 요청의
client_id가 동일한지 비교 - 토큰 요청에
redirect_uri가 필요한 서버라면, 인가 요청과 바이트 단위로 동일한지 점검 - 배포 환경에서 사용 중인 OAuth 설정(도메인, 콜백, client_id)을 런타임에 덤프해 확인
해결책
- 인가 요청과 토큰 교환을 수행하는 컴포넌트(웹/앱/백엔드)를 명확히 분리하고, 각자의
client_id를 혼용하지 않기 - 환경 변수 네이밍을 명확히 하고, 빌드 타임/런타임 주입이 섞이지 않게 구성
실전 디버깅 체크리스트(로그에 남길 것)
문제를 빠르게 좁히려면 아래 6가지를 "같은 트랜잭션" 으로 묶어 관찰하는 것이 핵심입니다.
state값(원문)code값(원문)code_verifierfingerprint(해시 일부)- 인가 요청의
code_challenge값 - 토큰 요청의
client_id와redirect_uri - 요청을 처리한 서버 인스턴스 ID(로드밸런싱 확인)
Node/Express 기준으로 콜백에서의 최소 로깅 예시는 아래와 같습니다.
import express from 'express';
import { fingerprint } from './fingerprint.js';
const app = express();
app.get('/oauth/callback', async (req, res) => {
const code = req.query.code;
const state = req.query.state;
// 예: Redis나 세션에서 state로 verifier 조회
const verifier = await req.sessionStore.get(`pkce:${state}`);
console.log('oauth.callback', {
state,
codeFp: code ? fingerprint(String(code)) : null,
verifierFp: verifier ? fingerprint(String(verifier)) : null,
instance: process.env.HOSTNAME
});
if (!state || !verifier || !code) {
return res.status(400).send('missing state/code/verifier');
}
// 여기서 토큰 교환 수행
res.send('ok');
});
마무리: 불일치는 "암호" 문제가 아니라 "경계" 문제인 경우가 많다
code_verifier 불일치는 SHA-256 구현이 틀려서라기보다, 대개 다음 중 하나입니다.
- 저장소/세션 수명 문제
- 동시 로그인으로 인한 매핑 꼬임
- Base64URL 규칙 미준수
- 프록시/라우팅이 파라미터를 변형
- 서로 다른 클라이언트/환경 설정 혼용
PKCE는 보안 기능이지만, 구현 관점에서는 "분산된 상태를 리다이렉트 경계 너머로 안전하게 운반" 하는 문제에 가깝습니다. 따라서 state 를 트랜잭션 키처럼 다루고, 로그는 원문 대신 fingerprint로 남기며, 세션은 공유 저장소로 일관되게 유지하는 쪽이 운영 안정성을 크게 올립니다.