- Published on
OAuth PKCE code_verifier 불일치 400 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 로그인이나 사내 SSO를 붙일 때 PKCE를 적용하면 보안은 좋아지지만, 토큰 교환 단계에서 갑자기 400이 떨어지며 code_verifier mismatch 혹은 invalid_grant 류의 에러를 마주치는 경우가 많습니다. 특히 SPA, 모바일 앱, BFF(Backend For Frontend) 조합에서는 리다이렉트와 저장소, 인코딩 이슈가 겹치면서 “분명 같은 값을 보냈는데 왜 다르지?” 상황이 자주 발생합니다.
이 글에서는 PKCE의 핵심 규칙을 다시 확인하고, 실제로 불일치가 발생하는 대표 패턴을 재현 가능한 형태로 정리한 뒤, 브라우저/서버/인증 서버별로 어디를 어떻게 로그로 확인해야 하는지, 그리고 구현을 어떻게 고치면 되는지까지 한 번에 정리합니다.
관련해서 invalid_grant로 뭉뚱그려 400이 나는 경우까지 포함한 원인/해결은 아래 글도 같이 보면 진단 속도가 빨라집니다.
PKCE에서 code_verifier 불일치가 의미하는 것
PKCE는 크게 두 번의 요청으로 성립합니다.
/authorize요청: 클라이언트가code_challenge를 포함해서 인가 코드를 받음/token요청: 클라이언트가code_verifier를 포함해서 인가 코드를 토큰으로 교환
인증 서버는 /token에서 받은 code_verifier로 다시 code_challenge를 계산한 뒤, /authorize 단계에서 저장해 둔 code_challenge와 비교합니다.
- 같으면 정상 발급
- 다르면
400(대개invalid_grant,code_verifier mismatch,PKCE verification failed등)
즉 “불일치”는 거의 항상 아래 둘 중 하나입니다.
/authorize에서 쓴code_challenge가 내가 의도한 값이 아니었다/token에서 보낸code_verifier가/authorize때의 그것과 다른 값으로 바뀌었다
먼저 확인해야 할 PKCE 규칙(스펙 관점 체크)
구현 버그를 잡기 전에, 기본 규칙을 어긴 것이 아닌지부터 확인합니다.
1) code_verifier 문자 집합과 길이
code_verifier는 보통 랜덤 바이트를 URL-safe 문자열로 변환해 만듭니다.
- 길이: 43 ~ 128
- 허용 문자: 영문, 숫자,
-,.,_,~
가장 흔한 실수는 “base64를 썼는데 +, /, =가 섞여 들어간 경우”입니다. 이 경우 인증 서버가 내부적으로 정규화하지 않으면 그대로 불일치가 납니다.
2) S256과 base64url 인코딩
code_challenge_method=S256인 경우 계산은 다음과 같습니다.
code_challenge = BASE64URL( SHA256( ASCII(code_verifier) ) )
여기서 포인트는 BASE64URL 입니다.
+는-로/는_로- 패딩
=제거
또한 해시 입력이 “문자열의 바이트 표현”이므로, 중간에 유니코드 정규화나 인코딩이 달라지면 다른 해시가 됩니다.
재현 가능한 최소 구현: 올바른 PKCE 생성 코드
아래 예시는 Node.js 환경에서 안전하게 code_verifier와 code_challenge를 만드는 코드입니다. 이 코드로 만든 값으로도 불일치가 난다면, 문제는 대개 “저장/전달 과정”에 있습니다.
// pkce.js (Node.js)
import crypto from 'crypto';
function base64UrlEncode(buf) {
return buf
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export function createPkcePair() {
// 32 bytes => base64url로 대략 43자 내외
const verifier = base64UrlEncode(crypto.randomBytes(32));
const challenge = base64UrlEncode(
crypto.createHash('sha256').update(verifier, 'ascii').digest()
);
return { verifier, challenge, method: 'S256' };
}
// 사용 예
const { verifier, challenge } = createPkcePair();
console.log({ verifierLength: verifier.length, verifier, challenge });
브라우저(Web Crypto)에서도 같은 방식으로 만들 수 있습니다.
// pkce.ts (Browser)
function base64UrlEncode(bytes: Uint8Array) {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
export async function createPkcePair() {
const random = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64UrlEncode(random);
const data = new TextEncoder().encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const challenge = base64UrlEncode(new Uint8Array(digest));
return { verifier, challenge, method: 'S256' as const };
}
불일치가 나는 대표 원인 10가지와 해결
아래는 실무에서 가장 많이 만나는 패턴들입니다. “왜 불일치가 나지?”를 “어디서 값이 바뀌었지?”로 바꿔서 추적하면 빨리 끝납니다.
1) base64와 base64url을 혼동
증상:
- 어떤 라이브러리는
code_challenge를 base64로 만들고, 어떤 쪽은 base64url로 비교 +또는/또는=가 끼어 있거나, 패딩 유무가 달라짐
해결:
code_verifier생성부터 base64url로 통일code_challenge도 반드시 base64url로 변환
점검:
code_verifier에+,/,=가 포함되는지 확인
2) code_verifier를 URL 파라미터로 흘려보냄
증상:
- 리다이렉트 과정에서
+가 공백으로 바뀌거나, 퍼센트 인코딩이 깨짐 - 프록시/미들웨어가 쿼리스트링을 재작성
해결:
code_verifier는 URL에 실어 보내지 말고, 클라이언트 저장소(메모리, 세션 스토리지, 서버 세션)에 보관- 토큰 요청에서만 바디로 전송
3) SPA에서 새로고침 또는 멀티탭으로 verifier 유실
증상:
/authorize요청을 보낸 탭과/callback을 처리하는 탭이 다름- 로그인 중 새로고침으로 메모리 상태가 초기화
- 탭 A에서 시작했는데 탭 B에서 callback 처리
해결:
code_verifier를sessionStorage에 저장(탭 단위 격리)- 멀티탭을 허용해야 한다면
state키로 매핑해서 여러 verifier를 저장
예시:
// authorize 시작 시
sessionStorage.setItem(`pkce:${state}`, verifier);
// callback 처리 시
const verifier = sessionStorage.getItem(`pkce:${state}`);
if (!verifier) throw new Error('Missing verifier for state');
4) state를 안 쓰거나, state와 verifier 매핑이 틀림
증상:
- 동시에 로그인 시도를 두 번 하면 마지막 verifier가 덮어써짐
- callback에서 엉뚱한 verifier를 가져옴
해결:
state를 반드시 사용하고,state별로 verifier를 저장- callback에서
state검증 실패 시 즉시 중단
5) BFF 도입 시 프론트와 백이 서로 다른 verifier를 생성
구조:
- 프론트가
/authorize를 만들면서 verifier/challenge 생성 - 토큰 교환은 백엔드가 수행
이때 프론트가 만든 verifier를 백엔드가 모르거나, 백엔드가 토큰 교환 시점에 verifier를 새로 만들면 무조건 불일치입니다.
해결:
- 권장: PKCE 생성과 토큰 교환을 같은 주체(BFF)에서 담당
- 또는: 프론트가 만든 verifier를 서버 세션에 안전하게 전달하고 저장
서버 세션 예시(Express):
app.get('/auth/start', (req, res) => {
const { verifier, challenge } = createPkcePair();
req.session.pkce = { verifier };
const url = new URL(process.env.AUTHZ_ENDPOINT);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.CLIENT_ID);
url.searchParams.set('redirect_uri', process.env.REDIRECT_URI);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('state', req.session.id);
res.redirect(url.toString());
});
app.get('/auth/callback', async (req, res) => {
const code = req.query.code;
const verifier = req.session.pkce?.verifier;
if (!verifier) return res.status(400).send('missing verifier');
// token 교환 시 verifier 사용
});
6) redirect_uri 불일치로 invalid_grant로 뭉개져 보임
일부 IdP는 PKCE 불일치와 redirect URI 불일치를 같은 invalid_grant로 내려서 혼동을 줍니다.
해결:
/authorize와/token에서redirect_uri가 완전히 동일한지 확인- 트레일링 슬래시, 포트, 스킴, 인코딩 차이까지 포함해서 일치해야 함
7) authorization code를 두 번 교환하거나, 오래된 code를 교환
증상:
- 네트워크 재시도 로직이
/token을 중복 호출 - callback 라우트가 두 번 실행(React Strict Mode 개발 환경, 이중 렌더링 등)
이 경우 IdP는 “이미 사용된 code”를 invalid_grant로 반환하고, 구현체에 따라 PKCE mismatch처럼 보이기도 합니다.
해결:
- callback 처리에 멱등성 부여(한 번만 교환)
- 프론트에서 callback 처리 후 즉시 URL에서
code제거
8) 라이브러리 간 PKCE 구현 차이(특히 모바일)
증상:
- iOS/Android 라이브러리가 내부적으로
plain을 쓰거나, verifier 길이가 규격 밖 - 커스텀 구현과 섞어서 사용
해결:
code_challenge_method가 실제로S256인지 확인- 라이브러리에서 생성한 verifier/challenge를 로그로 뽑아 서버에서 재계산해 비교
9) 프록시/게이트웨이가 바디를 변형하거나 인코딩을 바꿈
증상:
/token요청이application/x-www-form-urlencoded여야 하는데 JSON으로 보냄- WAF나 API Gateway가 폼 바디를 재작성
해결:
- IdP가 요구하는 Content-Type으로 전송
- 프록시 로그에서 원문 바디가 어떻게 나가는지 확인
Node 예시(폼 인코딩):
import fetch from 'node-fetch';
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: process.env.CLIENT_ID,
redirect_uri: process.env.REDIRECT_URI,
code,
code_verifier: verifier,
});
const resp = await fetch(process.env.TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
10) 로그를 안 남겨서 “불일치”가 추정만 되는 상태
PKCE는 값이 민감해서 로그를 남기기 꺼려지지만, 디버깅 단계에서는 최소한의 안전한 형태로 남겨야 합니다.
권장 로그(민감도 낮추기):
verifier원문 대신SHA-256(verifier)해시만 로그state,redirect_uri,code_challenge_method,code_challenge는 그대로 로그
예시:
function sha256Hex(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
logger.info('pkce_debug', {
state,
redirectUri,
challengeMethod: 'S256',
codeChallenge,
verifierHash: sha256Hex(verifier),
});
실전 디버깅 절차: 어디서 값이 바뀌는지 15분 안에 찾기
1) 클라이언트에서 /authorize 직전 값 확정
statecode_verifier(원문 저장 위치)code_challengecode_challenge_method
여기서 code_challenge를 “내가 직접 재계산한 값”과 비교합니다. 즉, 같은 함수로 verifier에서 다시 challenge를 만들어 보고 일치하는지 확인합니다.
2) 네트워크 탭에서 /authorize 요청 파라미터 확인
브라우저 개발자 도구에서 실제로 나간 code_challenge가 로컬 계산 값과 같은지 확인합니다.
- 다르면: URL 구성/인코딩 단계 버그
- 같으면: 다음 단계로
성능 문제로 디버깅이 어렵다면, DevTools에서 긴 작업을 잡아 UI 프리징을 줄이는 것도 도움이 됩니다.
3) callback에서 state로 verifier를 정확히 조회
sessionStorage나 서버 세션에서state키로 가져오는지- 동시에 여러 로그인 시도에서도 덮어쓰지 않는지
4) /token 요청 원문을 캡처
서버라면 egress 로그, 프록시 로그, 애플리케이션 로그로 아래를 확인합니다.
Content-Typeredirect_uri값code_verifier길이code가 callback에서 받은 값과 동일한지
5) 서버에서 code_verifier로 code_challenge 재계산
서버에서 재계산한 code_challenge가 /authorize 때 보낸 것과 같으면, PKCE 자체는 정상이므로 다른 원인(redirect_uri 불일치, code 재사용, 만료 등)을 의심합니다.
체크리스트: 이대로 맞추면 대부분 해결
code_verifier는 base64url로 생성하고 43자 이상인지 확인code_challenge는BASE64URL(SHA256(verifier))로 계산code_verifier는 URL로 전달하지 말고 저장소에 보관state를 사용하고state별로 verifier를 매핑/authorize와/token의redirect_uri를 완전 동일하게 유지- callback 라우트가 중복 실행되지 않게 멱등 처리
/token은application/x-www-form-urlencoded로 전송- 디버깅 시에는
verifier원문 대신 해시를 로그
마무리
PKCE code_verifier 불일치 400은 “암호학이 어려워서”라기보다, 대부분 “값을 생성한 주체와 저장/전달한 주체가 달라져서” 생깁니다. 위 절차대로 authorize 시점의 code_challenge와 token 시점의 code_verifier를 같은 기준으로 재계산해 비교하면, 어디서 값이 틀어졌는지 금방 드러납니다.
만약 에러 메시지가 invalid_grant로만 내려와 원인 분리가 어렵다면, PKCE 불일치 외에 code 재사용/만료/redirect URI 불일치까지 함께 점검할 수 있도록 정리한 글도 같이 참고하세요.