Published on

OAuth PKCE 검증 실패 400/401 원인 8가지

Authors

서드파티 OAuth 로그인(Authorization Code + PKCE)을 붙이다 보면, 토큰 교환 단계에서 갑자기 400 invalid_grant 또는 401 invalid_client/unauthorized를 맞는 경우가 많습니다. 특히 PKCE는 브라우저/앱에서 만든 code_verifier와 인증 서버가 저장한 code_challenge가 1바이트라도 다르면 곧바로 실패하기 때문에, “가끔만 실패”하는 형태로도 나타납니다.

이 글은 PKCE 검증 실패로 이어지는 400/401을 원인 8가지로 쪼개서, 증상 → 확인 포인트 → 해결책 순서로 정리합니다. (OAuth 공급자마다 에러 메시지는 다르지만, 본질은 거의 같습니다.)

PKCE 검증 흐름(최소 복습)

PKCE는 크게 두 단계로 동작합니다.

  1. Authorization Request: 클라이언트가 code_verifier를 생성하고, 이를 변환한 code_challenge/authorize에 보냅니다.

  2. 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_verifierauthorize 요청과 token 요청을 이어주는 유일한 비밀 값입니다. 그런데 SPA에서 아래처럼 구현하면 토큰 교환 시점에 값이 바뀝니다.

  • 컴포넌트 mount 때마다 code_verifier 생성
  • 메모리 변수에만 저장(리다이렉트 후 초기화)
  • Safari ITP/인앱 브라우저가 storage를 지우거나 제한

해결

  • code_verifier리다이렉트 전 생성하고, 리다이렉트 후에도 복원 가능한 저장소에 보관
    • SPA: sessionStorage(권장) 또는 안전한 쿠키(가능하면 HttpOnly는 서버가 생성/검증할 때)
  • 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_challengeBase64가 아니라 Base64URL입니다.

  • +-
  • /_
  • = 패딩 제거

일부 구현은 패딩을 남기거나, URL 인코딩을 이중 적용하거나, UTF-8 처리에서 삐끗합니다.

해결

  • 검증된 라이브러리 사용(가능하면 직접 구현 최소화)
  • 직접 구현 시 Base64URL 규격을 정확히 적용
  • 로그로 verifier/challenge를 찍을 때는 민감정보이므로 개발 환경에서만 제한적으로

원인 4) redirect_uri 불일치로 인한 invalid_grant

증상

  • 에러가 PKCE처럼 보이지만, 사실은 /token에서 invalid_grant
  • 공급자 콘솔에서 등록한 redirect와 1글자라도 다르면 실패

왜 발생하나

많은 OAuth 서버는 토큰 교환 시 다음을 묶어서 검증합니다.

  • code
  • client_id
  • redirect_uri
  • (PKCE) code_verifier

즉, PKCE가 맞아도 redirect_uri가 authorize 때와 token 때 다르면 실패합니다.

해결

  • authorize 요청과 token 요청에 동일한 redirect_uri를 사용
  • trailing slash(/callback vs /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 설정으로 파라미터가 누락/변조

증상

  • 로컬에서는 되는데 운영(프록시 뒤)에서만 실패
  • /callbackcode/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에서 401 invalid_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'

빠른 진단 체크리스트(운영에서 바로 쓰는 순서)

  1. authorize 요청code_challenge, code_challenge_method, state가 있는지 확인
  2. callbackcode, state가 그대로 돌아오는지 확인(프록시/리다이렉트로 유실 여부)
  3. state로 조회한 **동일한 code_verifier**를 /token에 보내는지 확인(재생성/유실 방지)
  4. redirect_uri가 authorize와 token에서 완전히 동일한지 확인
  5. 콜백 처리/토큰 교환이 중복 실행되지 않는지 확인
  6. /token이 401이면 PKCE보다 먼저 client 인증 방식을 점검
  7. 실패 요청의 서버 시간/지연을 확인(만료/시간 드리프트 의심)

마무리

PKCE 검증 실패는 “암호학이 어려워서”가 아니라, 대부분 상태 관리(저장/복원), 인코딩, 리다이렉트/프록시, 인증 방식 같은 구현 디테일에서 터집니다. 위 8가지를 순서대로 지우다 보면, 400/401을 만드는 원인이 거의 항상 한 군데로 수렴합니다.

운영 환경에서만 재현되는 경우가 많으니, 네트워크 경로(Ingress/ALB)와 시간 동기화(NTP)까지 포함해서 관찰하는 습관을 들이면 PKCE 이슈를 훨씬 빨리 끝낼 수 있습니다.