- Published on
OAuth 2.0 PKCE code_verifier 불일치 400 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 2.0 연동에서 PKCE를 붙이면, 로그인은 잘 되는데 토큰 교환 단계에서 400과 함께 code_verifier 관련 에러가 터지는 경우가 많습니다. 메시지는 공급자마다 다르지만 보통 invalid_grant, PKCE verification failed, code_verifier mismatch 같은 형태로 나타납니다.
이 글은 “왜 불일치가 발생하는지”를 원인별로 분해하고, 실제로 가장 많이 틀리는 포인트(인코딩, 저장 위치, 재사용, 요청 파라미터)를 코드로 검증하는 방식으로 해결합니다.
참고로, 구현 환경이 Next.js라면 로그인 리다이렉트와 콜백 처리 사이에서 상태(state) 및 임시 데이터 보관이 꼬이기 쉬운데, 렌더링/캐시/라우팅 구조도 함께 점검하는 게 좋습니다. 필요하면 Next.js App Router RSC 캐시·prefetch로 리렌더 제거도 같이 보세요.
PKCE 불일치가 나는 지점: 어디서 깨지나
PKCE는 요약하면 다음 순서입니다.
- 클라이언트가
code_verifier(랜덤 문자열)를 만든다 code_verifier로부터code_challenge를 만든다- Authorization Request에
code_challenge와code_challenge_method=S256를 포함해 로그인 페이지로 보낸다 - 콜백에서
authorization_code를 받는다 - Token Request에서
code_verifier를 함께 보내 토큰을 교환한다
불일치는 거의 항상 5번에서 발생합니다. 서버(Authorization Server)가 “처음 3번에서 받았던 challenge”와 “5번에서 받은 verifier”로 계산한 challenge가 다르다고 판단하는 상황입니다.
따라서 디버깅의 핵심은 한 줄입니다.
- “처음 만든
code_verifier가 콜백 이후 토큰 교환까지 그대로 전달되었는가?”
원인 1) code_verifier 생성 규칙 위반(길이/문자셋)
RFC 7636에서 code_verifier는 길이 43~128, 그리고 URL-safe 문자를 사용해야 합니다. 많은 라이브러리가 알아서 해주지만, 직접 만들 때 다음 실수가 잦습니다.
- 길이가 43 미만
+,/,=같은 문자가 섞인 Base64 문자열을 그대로 사용- URL 인코딩 과정에서 공백이나
%2B등으로 변형
Node.js에서 안전한 code_verifier 생성
아래는 문자셋을 강제로 URL-safe로 맞추는 예시입니다.
import crypto from 'crypto'
export function generateCodeVerifier(length = 64) {
// 32바이트면 base64url로 대략 43자 이상이 나옵니다.
// length를 직접 맞추고 싶다면 반복 생성 후 slice 하는 방식도 가능.
return crypto.randomBytes(32).toString('base64url')
}
포인트는 base64url입니다. base64를 쓴 뒤 수동 치환(+를 -, /를 _, = 제거)을 하는 방식도 되지만, 중간 과정에서 실수할 여지가 큽니다.
원인 2) code_challenge 계산이 Base64URL이 아님
S256 방식은 다음과 같습니다.
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
여기서 흔한 실수는 “Base64 인코딩”을 하고 끝내거나, = 패딩을 제거하지 않거나, URL-safe 치환을 하지 않는 것입니다.
올바른 code_challenge 계산 (Node.js)
import crypto from 'crypto'
export function generateCodeChallenge(codeVerifier: string) {
const hash = crypto.createHash('sha256').update(codeVerifier).digest()
// base64url은 패딩 제거와 URL-safe 치환을 자동 처리
return Buffer.from(hash).toString('base64url')
}
검증 팁:
- 토큰 교환 직전에, 현재 들고 있는
code_verifier로code_challenge를 다시 계산해보세요. - 그 값이 “처음 Authorization Request에 실어 보낸
code_challenge”와 동일해야 합니다.
원인 3) code_verifier를 URL 파라미터로 보내거나, 인코딩이 변형됨
code_verifier는 Token Request 본문에 실어 보내는 것이 일반적이며, URL 쿼리로 붙이면 중간 프록시/로그/리다이렉트에서 깨지거나 노출될 위험이 큽니다.
특히 다음과 같은 변형이 자주 발생합니다.
+가 공백으로 변환%2F등 퍼센트 인코딩이 이중 적용- 프레임워크가 자동으로 디코딩하면서 원문이 바뀜
해결 원칙:
code_verifier는 절대 Authorization Request URL에 포함하지 않습니다.- Token Request는
application/x-www-form-urlencoded또는 JSON(공급자 요구사항에 따름)으로 보내되, 라이브러리의 표준 방식을 따릅니다.
원인 4) 로그인 시작과 콜백 처리 사이에 code_verifier 저장이 깨짐
PKCE의 본질은 “콜백 이후에도 같은 verifier를 꺼내야 한다”입니다. 즉 상태 저장이 핵심입니다.
다음 케이스가 특히 많습니다.
- SPA에서 새로고침/탭 이동으로 메모리 상태가 날아감
- 여러 탭에서 동시에 로그인 시도하여 마지막 verifier로 덮어씀
- 서버리스 환경에서 in-memory 저장을 사용(요청 간 공유 안 됨)
- 쿠키에 저장했는데
SameSite/Secure설정 문제로 콜백에서 쿠키가 누락 - 세션 스토어에 저장했지만 콜백 요청이 다른 리전/인스턴스로 가서 세션 미스
권장 저장 전략
- 웹앱(브라우저)만 있는 경우:
sessionStorage를 많이 씁니다(탭 단위 격리) - SSR/Next.js: 서버 세션 스토어(예: Redis) 또는 암호화 쿠키 기반 세션
- 어떤 경우든 키를
state와 결합해 “로그인 시도 단위”로 분리
여러 탭 경합을 막는 방법: state를 키로 사용
// 로그인 시작 시
const state = crypto.randomBytes(16).toString('hex')
const codeVerifier = generateCodeVerifier()
sessionStorage.setItem(`pkce:${state}:verifier`, codeVerifier)
const codeChallenge = generateCodeChallenge(codeVerifier)
// authorization URL 생성 시 state 포함
// 콜백 처리 시
const state = new URLSearchParams(location.search).get('state')
if (!state) throw new Error('missing state')
const codeVerifier = sessionStorage.getItem(`pkce:${state}:verifier`)
if (!codeVerifier) throw new Error('missing code_verifier for state')
이 패턴은 “동시에 여러 번 로그인” 같은 현실적인 사용자 행동을 방어합니다.
원인 5) code_challenge_method 불일치 또는 누락
공급자에 따라 plain을 허용하지 않거나, 기본값이 plain로 해석되는 경우가 있습니다.
- Authorization Request에
code_challenge_method=S256를 누락 - Token Request는 S256 기준으로 보내고 있다고 착각
해결:
- Authorization Request에
code_challenge_method를 명시적으로 넣고, 값이S256인지 네트워크 탭에서 확인하세요.
원인 6) Token Request 파라미터/헤더가 공급자 요구와 다름
code_verifier가 맞아도, 공급자가 invalid_grant로 뭉뚱그려 400을 주는 경우가 있습니다. 이때는 다음도 같이 확인해야 합니다.
redirect_uri가 Authorization Request 때와 완전히 동일한지(문자 단위로)client_id가 올바른지- Token Endpoint에 POST로 보내는지
Content-Type이 요구대로인지code를 한 번 교환한 뒤 재사용하고 있지 않은지
redirect_uri 불일치가 PKCE 불일치처럼 보이는 경우
특히 redirect_uri는 다음 차이로도 실패합니다.
- 슬래시 하나 차이:
https://app.com/callbackvshttps://app.com/callback/ - 스킴 차이:
httpvshttps - 포트 포함 여부
- 쿼리스트링 포함 여부
공급자 로그가 없다면, “PKCE 불일치”라고만 보일 수 있습니다.
원인 7) 라이브러리/프레임워크가 내부적으로 PKCE를 생성하는데, 내가 또 생성함
다음 조합에서 많이 벌어집니다.
- 인증 SDK가 자동으로 PKCE를 생성하고 세션에 저장
- 개발자가 별도로
code_verifier를 만들어 토큰 교환 시 전달
결과적으로 “서버가 기대하는 verifier”와 “내가 보낸 verifier”가 다릅니다.
해결:
- 한 군데에서만 PKCE를 생성/보관/전달하도록 단일화
- SDK를 쓰면 SDK가 제공하는 콜백 핸들러/토큰 교환 함수를 그대로 사용
재현 가능한 디버깅 체크리스트
아래 순서대로 하면 원인을 빠르게 좁힐 수 있습니다.
1) Authorization Request에서 실제로 나간 값 캡처
브라우저 네트워크 탭 또는 서버 로그로 다음을 저장합니다.
statecode_challengecode_challenge_methodredirect_uri
이 값은 “진실의 원본”입니다.
2) 콜백에서 state로 code_verifier를 정확히 조회
- 콜백에서
state가 존재하는지 pkce:${state}:verifier같은 키로 조회했을 때 값이 있는지- 값 길이가 43~128인지
3) 토큰 교환 직전에 로컬에서 challenge 재계산
const recomputed = generateCodeChallenge(codeVerifier)
if (recomputed !== originalChallenge) {
throw new Error('PKCE local verification failed: verifier mutated or mismatched')
}
이 검증이 실패하면, 공급자 탓이 아니라 100% 클라이언트/세션 저장/인코딩 문제입니다.
4) Token Request를 원시 형태로 확인
가능하면 curl로 동일 요청을 재현해 보세요.
curl -X POST 'https://oauth.example.com/token' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=YOUR_CLIENT_ID' \
--data-urlencode 'redirect_uri=https://app.example.com/callback' \
--data-urlencode 'code=AUTHORIZATION_CODE' \
--data-urlencode 'code_verifier=YOUR_CODE_VERIFIER'
여기서 --data-urlencode를 쓰면 인코딩 실수를 크게 줄일 수 있습니다.
Next.js에서 특히 자주 터지는 패턴과 처방
App Router에서 콜백이 RSC/캐시 영향으로 재실행되는 문제
콜백 라우트에서 토큰 교환을 수행할 때, 의도치 않게 재요청/재실행이 발생하면 authorization_code를 재사용하게 되어 400이 납니다. 메시지가 PKCE처럼 보일 수도 있습니다.
- 콜백 처리 로직은 가능한 한 “한 번만” 실행되도록 설계
- 서버 액션/라우트 핸들러에서 캐시를 끄거나, idempotency를 고려
이 이슈는 PKCE 자체 문제라기보다 “요청이 예상보다 여러 번 발생”하는 구조 문제인 경우가 많습니다. Next.js의 캐시/프리패치 특성을 이해하면 예방에 도움이 됩니다. 자세한 배경은 Next.js App Router RSC 캐시·prefetch로 리렌더 제거를 참고하세요.
쿠키 기반 저장 시 SameSite로 콜백 쿠키 누락
OAuth 콜백은 외부 도메인에서 내 도메인으로 돌아오는 크로스 사이트 네비게이션입니다. 쿠키를 verifier 저장소로 쓴다면 다음을 점검하세요.
SameSite=Lax는 보통 top-level GET 네비게이션에 쿠키를 보냅니다(현대 브라우저 기준). 하지만 공급자/플로우에 따라 예외가 생길 수 있습니다.SameSite=None을 쓰면 반드시Secure가 필요합니다.
콜백에서 verifier 쿠키가 안 보이면, 서버는 “verifier 없음” 또는 “다른 verifier”로 처리하게 됩니다.
안전한 구현 예시: PKCE 유틸 + 저장 + 검증
아래는 핵심만 모은 예시입니다.
import crypto from 'crypto'
export function generateState() {
return crypto.randomBytes(16).toString('hex')
}
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url')
}
export function generateCodeChallenge(verifier: string) {
const hash = crypto.createHash('sha256').update(verifier).digest()
return Buffer.from(hash).toString('base64url')
}
// 로그인 시작 (브라우저)
const state = generateState()
const verifier = generateCodeVerifier()
const challenge = generateCodeChallenge(verifier)
sessionStorage.setItem(`pkce:${state}:verifier`, verifier)
const authUrl = new URL('https://oauth.example.com/authorize')
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID')
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
location.href = authUrl.toString()
// 콜백 처리 (브라우저에서 code/state를 받아 서버로 교환 요청)
const qs = new URLSearchParams(location.search)
const code = qs.get('code')
const state = qs.get('state')
if (!code || !state) throw new Error('missing code/state')
const verifier = sessionStorage.getItem(`pkce:${state}:verifier`)
if (!verifier) throw new Error('missing verifier')
await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, state, verifier }),
})
서버의 /api/oauth/token에서는 verifier를 그대로 공급자 Token Endpoint에 전달하되, 서버 쪽에서도 verifier를 다시 계산해 “내가 기억하는 challenge와 일치하는지”를 검증하면 디버깅 시간이 크게 줄어듭니다.
마무리: 가장 흔한 3가지만 먼저 의심하자
현장에서 code_verifier 불일치 400의 원인은 대부분 아래 셋 중 하나로 수렴합니다.
- Base64URL이 아닌 인코딩(패딩
=포함,+//포함, 치환 누락) - 콜백까지 verifier 저장이 유지되지 않음(세션/쿠키/SameSite/서버리스 메모리)
- 여러 로그인 시도 경합으로 verifier가 덮어씀(
state로 분리 안 함)
위 체크리스트대로 “원본 code_challenge 캡처”와 “토큰 교환 직전 재계산”만 해도, 문제의 80%는 공급자 문의 없이 자체 해결할 수 있습니다.
추가로, 인증 이슈는 겉보기 증상이 비슷해 원인 추적이 어렵습니다. 다른 분야에서도 비슷하게 “원인을 분해하고 검증 포인트를 만드는 방식”이 유효한데, 예를 들어 API 스키마 검증 오류를 유형별로 쪼개 해결하는 접근은 Pydantic v2 FastAPI 응답 검증 에러 7종 해결법도 참고할 만합니다.