- Published on
OAuth PKCE 검증 실패 400/401 원인 8가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth 로그인(Authorization Code + PKCE)을 붙이다 보면, 토큰 교환 단계에서 갑자기 400 invalid_grant 또는 401 invalid_client/unauthorized를 맞는 경우가 많습니다. 특히 PKCE는 브라우저/앱에서 만든 code_verifier와 인증 서버가 저장한 code_challenge가 1바이트라도 다르면 곧바로 실패하기 때문에, “가끔만 실패”하는 형태로도 나타납니다.
이 글은 PKCE 검증 실패로 이어지는 400/401을 원인 8가지로 쪼개서, 증상 → 확인 포인트 → 해결책 순서로 정리합니다. (OAuth 공급자마다 에러 메시지는 다르지만, 본질은 거의 같습니다.)
PKCE 검증 흐름(최소 복습)
PKCE는 크게 두 단계로 동작합니다.
Authorization Request: 클라이언트가
code_verifier를 생성하고, 이를 변환한code_challenge를/authorize에 보냅니다.Token Request:
/token호출 시authorization_code와 함께 **원본code_verifier**를 보내면, 서버가code_verifier -> code_challenge를 다시 계산해 1)에서 받은 값과 비교합니다.
즉, 실패는 대부분 아래 중 하나입니다.
- 토큰 요청에 **틀린/다른
code_verifier**를 보냄 - 최초 authorize에 **틀린
code_challenge**를 보냄 - 둘 다 맞지만, 서버가 비교할 상태(code, challenge)를 잃어버림
아래 원인들을 보면 “왜 400/401이 나오는지”가 구조적으로 이해될 겁니다.
원인 1) code_verifier를 재생성(세션/스토리지 유실)
증상
/authorize로 리다이렉트는 잘 되는데/token에서invalid_grant(400)- 새로고침/뒤로가기/탭 이동 후에만 실패
- 모바일 웹/인앱 브라우저에서 간헐적
왜 발생하나
code_verifier는 authorize 요청과 token 요청을 이어주는 유일한 비밀 값입니다. 그런데 SPA에서 아래처럼 구현하면 토큰 교환 시점에 값이 바뀝니다.
- 컴포넌트 mount 때마다
code_verifier생성 - 메모리 변수에만 저장(리다이렉트 후 초기화)
- Safari ITP/인앱 브라우저가 storage를 지우거나 제한
해결
code_verifier는 리다이렉트 전 생성하고, 리다이렉트 후에도 복원 가능한 저장소에 보관- SPA:
sessionStorage(권장) 또는 안전한 쿠키(가능하면 HttpOnly는 서버가 생성/검증할 때)
- SPA:
state키로code_verifier를 매핑해 다중 로그인 시도도 안전하게 처리
// (브라우저) PKCE verifier/challenge 생성 + sessionStorage 저장 예시
function base64url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
async function sha256(input) {
const data = new TextEncoder().encode(input);
const hash = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(hash);
}
function randomString(len = 64) {
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
return base64url(bytes);
}
export async function startLogin() {
const state = randomString(32);
const verifier = randomString(64);
const challenge = base64url(await sha256(verifier));
sessionStorage.setItem(`pkce:${state}:verifier`, 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: "S256",
});
location.href = `https://idp.example.com/oauth2/authorize?${params}`;
}
원인 2) code_challenge_method 불일치(S256 vs plain)
증상
- 특정 공급자에서만 지속적으로 실패
- 에러 메시지에
code_verifier관련 문구가 등장하거나, 그냥invalid_grant
왜 발생하나
code_challenge를 SHA-256으로 만들었는데 code_challenge_method=plain으로 보내거나, 반대로 plain인데 S256으로 보내면 서버가 계산 방식이 달라져 검증이 실패합니다.
해결
- 가능하면 무조건
S256사용 - authorize 요청에
code_challenge_method=S256가 실제로 포함되는지 네트워크 탭에서 확인
# authorize 요청 예시(정상)
https://idp.example.com/oauth2/authorize?
response_type=code&client_id=...&redirect_uri=...&
code_challenge=...&code_challenge_method=S256
원인 3) Base64URL 인코딩 실수(패딩/문자 치환)
증상
- “대부분 되는데 일부 환경에서만” 실패
- 서버/라이브러리 버전 바꾸면 갑자기 깨짐
왜 발생하나
PKCE의 code_challenge는 Base64가 아니라 Base64URL입니다.
+→-/→_=패딩 제거
일부 구현은 패딩을 남기거나, URL 인코딩을 이중 적용하거나, UTF-8 처리에서 삐끗합니다.
해결
- 검증된 라이브러리 사용(가능하면 직접 구현 최소화)
- 직접 구현 시 Base64URL 규격을 정확히 적용
- 로그로
verifier/challenge를 찍을 때는 민감정보이므로 개발 환경에서만 제한적으로
원인 4) redirect_uri 불일치로 인한 invalid_grant
증상
- 에러가 PKCE처럼 보이지만, 사실은
/token에서invalid_grant - 공급자 콘솔에서 등록한 redirect와 1글자라도 다르면 실패
왜 발생하나
많은 OAuth 서버는 토큰 교환 시 다음을 묶어서 검증합니다.
codeclient_idredirect_uri- (PKCE)
code_verifier
즉, PKCE가 맞아도 redirect_uri가 authorize 때와 token 때 다르면 실패합니다.
해결
- authorize 요청과 token 요청에 동일한 redirect_uri를 사용
- trailing slash(
/callbackvs/callback/)도 동일하게 - 프록시/Ingress 뒤에서 외부 URL이 바뀌는 경우(HTTP→HTTPS) 특히 주의
원인 5) Authorization Code 재사용/중복 교환(레이스 컨디션)
증상
- 첫 시도는 성공, 같은
code로 다시 호출하면 400 - 프론트에서 콜백 처리 로직이 두 번 실행될 때(React StrictMode, 라우터 이중 진입)
왜 발생하나
Authorization Code는 1회용입니다. 콜백 페이지에서 토큰 교환을 두 번 호출하면 두 번째는 실패합니다. 이때 에러가 PKCE 실패처럼 보이기도 합니다.
해결
- 콜백 처리 함수에 idempotency 가드 추가
code를 처리했으면 즉시 URL에서 제거(replaceState)하고, 재진입 방지
// 콜백에서 code 1회만 처리
let exchanging = false;
export async function handleCallback() {
if (exchanging) return;
exchanging = true;
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");
// URL에서 code 제거(새로고침 시 재교환 방지)
url.searchParams.delete("code");
url.searchParams.delete("state");
history.replaceState({}, "", url.toString());
// ... token exchange
}
원인 6) 시간 드리프트/만료로 인한 code 만료(특히 서버측 교환)
증상
- 간헐적으로
invalid_grant(code expired) - 컨테이너/노드 교체 이후부터 실패율 증가
왜 발생하나
Authorization Code는 만료가 매우 짧습니다(수십 초~수분). 서버 시간이 틀어져 있거나, 네트워크 지연/큐잉으로 토큰 교환이 늦어지면 만료로 실패합니다. 운영 환경(EKS 등)에서는 노드 시간 드리프트가 의외로 자주 원인이 됩니다.
시간 동기화 이슈는 PKCE 자체 오류가 아니라도 결과적으로 /token에서 400을 만들고, 로그만 보면 PKCE로 오인하기 쉽습니다.
해결
- NTP/chrony 동기화 확인
- 콜백 수신 후 토큰 교환까지의 경로를 짧게(백엔드에서 즉시 교환)
관련해서 클러스터 시간 문제를 다룬 글도 함께 참고하면 진단에 도움이 됩니다: EKS Pod 시간 드리프트로 STS·TLS 실패 해결하기
원인 7) 프록시/Ingress 설정으로 파라미터가 누락/변조
증상
- 로컬에서는 되는데 운영(프록시 뒤)에서만 실패
/callback에code/state가 안 오거나 일부만 옴- 긴 URL에서 특정 쿼리 파라미터가 잘림
왜 발생하나
ALB/Ingress/Nginx/WAF가 다음을 건드리면 OAuth 흐름이 깨집니다.
- 쿼리 스트링 길이 제한
- 특정 파라미터 필터링(
code,state를 공격 패턴으로 오탐) - HTTP→HTTPS 리다이렉트 과정에서 쿼리 유실
그러면 결과적으로 state 매칭 실패 → 잘못된 verifier 사용 → PKCE 검증 실패로 이어질 수 있습니다.
해결
- 콜백 엔드포인트에서 원본 요청 URL 전체를 서버 로그로 남겨(민감정보 마스킹) 누락 여부 확인
- Ingress/프록시의 리다이렉트 규칙과 쿼리 보존 여부 점검
프록시/ALB 이슈를 다룬 글을 함께 보면 “운영에서만 깨지는” 패턴을 잡는 데 도움이 됩니다: EKS ALB Ingress 502 target timeout 원인·해결
원인 8) 클라이언트 인증 방식 오류로 401(Confidential vs Public 혼동)
증상
/token에서 401invalid_client- PKCE를 썼는데도 client_secret 요구/검증에서 실패
왜 발생하나
PKCE는 “public client에서도 안전하게 authorization code를 쓸 수 있게” 해주지만, 공급자 설정/앱 유형에 따라 /token 호출 시 클라이언트 인증 방식이 달라집니다.
- 백엔드가 있는 confidential client:
client_secret또는private_key_jwt필요 - SPA/모바일 public client: 보통 secret 없이 PKCE로 처리(또는 특정 방식 요구)
여기서 흔한 실수:
- SPA인데 secret을 프론트에 넣음(보안상 금지) → 결국 설정 꼬임
- 서버는 Basic Auth로
client_id:client_secret을 보내야 하는데 body로 보내서 거절 - 반대로 public client인데 secret을 보내면 정책상 거절하는 IdP도 존재
해결
- 공급자 콘솔에서 앱 타입/토큰 엔드포인트 인증 방식을 먼저 확정
/token호출 시 인증 헤더/바디 포맷을 정확히 맞춤
# confidential client 예시: Basic Auth + x-www-form-urlencoded
curl -X POST https://idp.example.com/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Authorization: Basic BASE64(client_id:client_secret)' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'code=AUTH_CODE' \
--data-urlencode 'redirect_uri=https://app.example.com/callback' \
--data-urlencode 'code_verifier=ORIGINAL_VERIFIER'
빠른 진단 체크리스트(운영에서 바로 쓰는 순서)
- authorize 요청에
code_challenge,code_challenge_method,state가 있는지 확인 - callback에
code,state가 그대로 돌아오는지 확인(프록시/리다이렉트로 유실 여부) state로 조회한 **동일한code_verifier**를/token에 보내는지 확인(재생성/유실 방지)redirect_uri가 authorize와 token에서 완전히 동일한지 확인- 콜백 처리/토큰 교환이 중복 실행되지 않는지 확인
/token이 401이면 PKCE보다 먼저 client 인증 방식을 점검- 실패 요청의 서버 시간/지연을 확인(만료/시간 드리프트 의심)
마무리
PKCE 검증 실패는 “암호학이 어려워서”가 아니라, 대부분 상태 관리(저장/복원), 인코딩, 리다이렉트/프록시, 인증 방식 같은 구현 디테일에서 터집니다. 위 8가지를 순서대로 지우다 보면, 400/401을 만드는 원인이 거의 항상 한 군데로 수렴합니다.
운영 환경에서만 재현되는 경우가 많으니, 네트워크 경로(Ingress/ALB)와 시간 동기화(NTP)까지 포함해서 관찰하는 습관을 들이면 PKCE 이슈를 훨씬 빨리 끝낼 수 있습니다.