Published on

OAuth PKCE code_verifier 불일치 5가지 원인

Authors

서드파티 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) 멀티탭/동시 로그인으로 statecode_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에 codestate 를 붙여 리다이렉트합니다. 그런데 중간에 있는 구성 요소가 다음을 일으킬 수 있습니다.

  • 리라이트/리다이렉트 규칙이 쿼리를 보존하지 않음
  • 프록시가 특정 파라미터를 보안 정책으로 제거
  • 앱 라우터가 콜백을 처리하는 과정에서 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가지를 "같은 트랜잭션" 으로 묶어 관찰하는 것이 핵심입니다.

  1. state 값(원문)
  2. code 값(원문)
  3. code_verifier fingerprint(해시 일부)
  4. 인가 요청의 code_challenge
  5. 토큰 요청의 client_idredirect_uri
  6. 요청을 처리한 서버 인스턴스 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로 남기며, 세션은 공유 저장소로 일관되게 유지하는 쪽이 운영 안정성을 크게 올립니다.