Published on

OAuth PKCE 실패 401 invalid_grant 실전 진단

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, OAuth 2.0 Authorization Code + PKCE에서 토큰 교환 단계가 401 또는 invalid_grant로 떨어지는 순간이 꼭 옵니다. 문제는 이 에러가 “인증 서버가 코드 교환을 거부했다”는 결과만 말해줄 뿐, 원인이 코드 만료인지, 리다이렉트 URI 불일치인지, code_verifier 불일치인지, 클라이언트 인증 방식 문제인지를 직접 추적해야 한다는 점입니다.

이 글은 PKCE 실패를 재현 가능한 형태로 관찰하고, 가장 흔한 원인을 우선순위대로 제거하는 실전 진단 흐름을 제공합니다.

참고: 네트워크/프록시 계층에서 요청이 변형되는 경우도 많습니다. 인프라 단에서의 리셋/타임아웃류 문제를 함께 다루는 글로는 EKS ALB Ingress 500 Target reset 원인·해결도 같이 보면 도움이 됩니다.

PKCE에서 invalid_grant가 의미하는 것

OAuth 서버 구현체마다 메시지는 조금씩 다르지만, 토큰 엔드포인트에서 invalid_grant는 대개 아래 범주 중 하나입니다.

  • Authorization Code가 유효하지 않음
    • 이미 사용됨(재사용)
    • 만료됨
    • 잘못된 클라이언트/리다이렉트 URI에 묶인 코드
  • PKCE 검증 실패
    • code_verifier가 다름
    • code_challenge_method 불일치
    • 인코딩/정규화 문제
  • 요청 파라미터 불일치
    • redirect_uri가 인가 요청과 토큰 요청에서 불일치
    • client_id 불일치
  • 클라이언트 인증 문제
    • Public client인데 client_secret을 보내거나, 반대로 Confidential client인데 인증이 누락됨

핵심은 “토큰 교환 요청 한 번”이 아니라, 인가 요청부터 토큰 요청까지의 연속된 상관관계를 확인해야 한다는 점입니다.

진단 0단계: 실패를 관측 가능하게 만들기

먼저 아래 3가지를 확보하면 진단 속도가 급격히 빨라집니다.

  1. 브라우저/앱에서 실제로 나간 인가 요청 URL
  2. 토큰 엔드포인트로 나간 HTTP 요청 전문(헤더/바디)
  3. OAuth 서버 로그에서 해당 요청의 트레이스(가능하면 request_id)

프론트/백/게이트웨이/인증 서버 사이를 오가며 디버깅해야 하므로, 요청에 상관 ID를 붙여두는 것이 좋습니다.

토큰 요청을 cURL로 고정해 재현하기

실패 케이스를 가장 빨리 고정하는 방법은 “앱이 만든 요청을 그대로” cURL로 재현하는 것입니다.

curl -i -X POST "https://auth.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=my-client" \
  --data-urlencode "code=AUTH_CODE_FROM_CALLBACK" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=VERIFIER_FROM_STORAGE"

여기서 redirect_uricode_verifier는 “추정”이 아니라, 실제 런타임에서 사용된 값을 그대로 가져와야 합니다.

1단계: redirect_uri 불일치부터 의심하기

PKCE 실패로 보이지만, 실제로는 redirect_uri 불일치가 가장 흔합니다. 특히 아래 상황에서 자주 발생합니다.

  • 인가 요청에서는 https://app.example.com/callback인데, 토큰 요청에서 https://app.example.com/callback/처럼 슬래시가 붙음
  • 로컬 개발에서 인가 요청은 http://localhost:3000/callback인데 토큰 요청은 http://127.0.0.1:3000/callback
  • 프록시/Ingress 뒤에서 외부는 HTTPS인데 내부 앱이 HTTP로 인식해 redirect_uri를 다르게 생성

체크 포인트

  • 인가 요청의 redirect_uri와 토큰 요청의 redirect_uri문자열 완전 일치여야 합니다.
  • 서버 설정에 등록된 리다이렉트 URI도 동일하게 일치해야 합니다.

프록시 뒤에서 스킴이 바뀌는 문제

Ingress나 리버스 프록시 뒤에서 X-Forwarded-Proto 처리가 잘못되면, 앱이 http로 인식해 콜백 URL을 잘못 만들 수 있습니다.

Node/Express 예시:

import express from "express";

const app = express();
app.set("trust proxy", true); // 프록시 뒤에서 원래 스킴/호스트 인식

app.get("/login", (req, res) => {
  const redirectUri = `${req.protocol}://${req.get("host")}/callback`;
  res.send(redirectUri);
});

이 설정이 없으면 req.protocol이 내부 통신 기준으로 잡혀 http가 되어, 결과적으로 redirect_uri 불일치로 invalid_grant가 날 수 있습니다.

2단계: Authorization Code “재사용/중복 교환” 확인

인가 코드(authorization code)는 보통 1회성입니다. 아래 같은 버그가 있으면 토큰 교환이 2번 발생해 두 번째 요청이 invalid_grant로 실패합니다.

  • SPA에서 콜백 페이지가 리로드되며 토큰 교환이 다시 실행됨
  • 모바일에서 딥링크 처리 중 콜백 핸들러가 중복 호출됨
  • 백엔드와 프론트가 각각 토큰 교환을 시도함

빠른 확인법

  • 토큰 엔드포인트 요청 로그가 동일한 code로 2번 이상 발생하는지 확인
  • 프론트에서 콜백 처리 후 URL에서 code 파라미터를 제거했는지 확인

SPA에서 콜백 처리 후 주소 정리 예시:

const url = new URL(window.location.href);
if (url.searchParams.get("code")) {
  // 토큰 교환 로직 수행
  // ...

  // 교환 후 code 제거
  url.searchParams.delete("code");
  url.searchParams.delete("state");
  window.history.replaceState({}, document.title, url.toString());
}

3단계: PKCE 핵심인 code_verifier 불일치 점검

PKCE에서 가장 본질적인 실패 원인은 code_verifier가 토큰 요청에서 달라지는 것입니다.

흔한 원인

  • 인가 요청 시 생성한 code_verifier를 세션/스토리지에 저장하지 못함
    • Safari ITP, 서드파티 쿠키 차단, 인앱 브라우저의 스토리지 정책
  • 콜백 도메인이 달라져 저장소가 분리됨
    • app.example.com에서 시작했는데 콜백이 www.example.com으로 옴
  • 서버 사이드에서 verifier를 저장했는데 로드 밸런싱으로 다른 인스턴스에 붙음
    • sticky session 미설정
    • 세션 저장소가 인메모리

verifier 저장 전략

  • SPA: sessionStorage에 저장(탭 단위) + 콜백 처리 즉시 삭제
  • BFF/서버: Redis 같은 외부 세션 저장소에 state 키로 매핑

Redis에 state로 매핑하는 예시(개념 코드):

// 인가 요청 시작
const state = crypto.randomUUID();
const codeVerifier = generateVerifier();

await redis.setex(`pkce:${state}`, 300, codeVerifier);

// 콜백에서 토큰 교환
const savedVerifier = await redis.get(`pkce:${state}`);
if (!savedVerifier) throw new Error("PKCE verifier missing");

여기서 TTL은 인가 코드 만료 시간보다 조금 짧거나 비슷하게 잡는 편이 운영상 안전합니다.

4단계: code_challenge_method와 해시/인코딩 실수

대부분은 S256을 씁니다. 이때 code_challenge는 다음 규칙을 만족해야 합니다.

  • code_challenge = BASE64URL( SHA256( code_verifier ) )
  • Base64URL은 +-로, /_로 바꾸고, = 패딩을 제거

여기서 흔한 실수는 아래와 같습니다.

  • Base64를 그대로 보내서 +, /, =가 남아있음
  • URL 인코딩을 잘못해서 값이 변형됨
  • code_verifier를 생성할 때 허용 문자 규칙을 위반

Node.js에서 올바른 S256 생성

import crypto from "crypto";

function base64url(buffer) {
  return buffer
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

export function createPkcePair() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
  return { verifier, challenge, method: "S256" };
}

서버 로그에 PKCE verification failed류가 찍히면, 우선 위 로직으로 생성한 값과 현재 구현을 비교해보는 것이 빠릅니다.

5단계: 클라이언트 유형(Public/Confidential)과 인증 방식 충돌

PKCE는 원래 Public client(네이티브/SPA)에서 client_secret 없이도 안전하게 코드를 교환하기 위한 장치입니다. 그런데 설정이 꼬이면 아래 문제가 발생합니다.

  • 서버는 Confidential client로 등록되어 client_secret 또는 client_assertion을 요구
  • 클라이언트는 Public로 구현되어 secret을 보낼 수 없음
  • 반대로 Public로 등록했는데 서버/라이브러리가 Basic Auth 헤더를 붙여버림

체크 포인트

  • OAuth 서버의 클라이언트 설정에서 “PKCE required”, “client authentication method”를 확인
  • 토큰 요청에 Authorization: Basic ...가 붙는지 확인

cURL에서 Basic Auth를 쓰는 형태(Confidential client일 때):

curl -i -X POST "https://auth.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "my-client:MY_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=VERIFIER"

Public client라면 보통 -u를 쓰지 않고 client_id만 보냅니다(서버 정책에 따름).

6단계: 시간/만료/클럭 스큐와 재시도 정책

인가 코드는 만료가 짧습니다(수십 초~수 분). 다음 상황이면 만료로 invalid_grant가 납니다.

  • 사용자가 로그인 화면에서 오래 머뭄
  • 모바일 네트워크에서 콜백 후 토큰 요청이 지연
  • 백엔드가 토큰 요청을 큐잉/재시도하며 늦게 보냄

실전 팁

  • 토큰 교환은 “재시도”를 신중히 해야 합니다. 네트워크 오류는 재시도하되, 동일 code로 재시도하면 이미 사용 처리될 수 있습니다.
  • 서버와 클라이언트의 시간 동기화도 확인(NTP).

7단계: state 검증 실패를 invalid_grant로 오해하지 않기

엄밀히 말하면 state는 토큰 요청의 파라미터가 아니라 인가 응답에서 검증하는 값이지만, 실제 구현에서는 state가 틀려 콜백 처리가 중단되고, 그 결과 토큰 교환에 잘못된 값이 들어가 invalid_grant로 이어지는 경우가 있습니다.

  • state를 세션에 저장했는데 세션이 유실
  • 멀티 탭 로그인으로 state가 덮어쓰기

해결은 state를 탭 단위 저장소에 두거나, 로그인 시도별로 별도 키로 관리하는 것입니다.

8단계: 프록시/WAF가 파라미터를 변형하는지 확인

아주 드물지만 운영 환경에서만 재현되는 케이스로, WAF/프록시가 폼 바디를 검사/정규화하며 특정 문자를 변형하는 일이 있습니다.

  • application/x-www-form-urlencoded 바디의 +를 공백으로 처리
  • URL 인코딩을 중간에서 재인코딩

이 문제는 특히 code_verifiercode+가 포함될 때 치명적입니다. 그래서 verifier는 아예 Base64URL처럼 안전한 문자 집합으로 만들고, 전송 시에는 --data-urlencode처럼 확실한 인코딩 방식을 사용하세요.

로그로 원인을 좁히는 체크리스트

운영에서 빠르게 원인 분류를 하려면 아래 순서가 효율적입니다.

  1. 동일 code로 토큰 요청이 2번 이상인가
  2. 인가 요청의 redirect_uri와 토큰 요청의 redirect_uri가 완전 일치인가
  3. code_verifier를 “인가 요청 생성 시점”과 “토큰 요청 시점”에서 동일하게 유지했나
  4. S256 생성 로직이 Base64URL 규칙을 지키나
  5. 클라이언트 인증 방식이 서버 설정과 맞나
  6. 만료/지연/재시도 정책으로 코드가 늦게 교환되지는 않나

이런 식의 “에러 코드 1개를 여러 가능성으로 분해”하는 접근은 다른 API 오류 디버깅에도 그대로 통합니다. 예를 들어 LLM API에서 400 invalid_request를 원인별로 쪼개는 방식은 LangChain Tool Calling 400 invalid_request 오류 9가지와도 결이 같습니다.

실전 구성 예시: BFF에서 PKCE를 안정적으로 처리하기

SPA 단독 구현은 브라우저 정책 영향(스토리지, 쿠키, ITP)을 크게 받습니다. 운영 안정성이 중요하면 BFF(Backend For Frontend)에서 PKCE를 처리하는 구성이 실전에서 많이 쓰입니다.

  • 프론트는 /auth/start로 이동
  • BFF가 statecode_verifier를 생성하고 Redis에 저장
  • BFF가 OAuth 서버로 리다이렉트
  • 콜백은 BFF가 받고, Redis에서 verifier를 꺼내 토큰 교환
  • 프론트에는 세션 쿠키만 내려줌

이렇게 하면 code_verifier 유실, 멀티 탭, 도메인 분리 문제를 상당 부분 제거할 수 있습니다.

간단한 Express 라우팅 스케치:

app.get("/auth/start", async (req, res) => {
  const { verifier, challenge, method } = createPkcePair();
  const state = crypto.randomUUID();

  await redis.setex(`pkce:${state}`, 300, verifier);

  const authorizeUrl = new URL("https://auth.example.com/oauth/authorize");
  authorizeUrl.searchParams.set("response_type", "code");
  authorizeUrl.searchParams.set("client_id", "my-client");
  authorizeUrl.searchParams.set("redirect_uri", "https://app.example.com/auth/callback");
  authorizeUrl.searchParams.set("state", state);
  authorizeUrl.searchParams.set("code_challenge", challenge);
  authorizeUrl.searchParams.set("code_challenge_method", method);

  res.redirect(authorizeUrl.toString());
});

app.get("/auth/callback", async (req, res) => {
  const code = req.query.code;
  const state = req.query.state;

  const verifier = await redis.get(`pkce:${state}`);
  if (!verifier) return res.status(400).send("state/verifier missing");

  // 여기서 토큰 요청 수행 (code + verifier)
  res.send("ok");
});

마무리: invalid_grant는 “결과”이고, 원인은 연결고리에 있다

PKCE에서 401/invalid_grant는 대부분 “토큰 교환 요청이 인가 요청과 같은 흐름이 아니다”라는 신호입니다. 즉, 아래 연결고리 중 하나가 끊어진 것입니다.

  • 인가 요청과 토큰 요청의 redirect_uri 일치
  • 1회성 코드 재사용 방지
  • code_verifier의 안정적 저장/전달
  • 올바른 S256 인코딩
  • 클라이언트 인증 방식 일치

위 체크리스트대로 한 단계씩 제거하면, 막연한 PKCE 디버깅이 아니라 재현 가능한 형태로 원인을 확정할 수 있습니다.

추가로, 이런 인증/토큰 교환 문제는 배포/캐시/프록시 변화에 의해 갑자기 발생하기도 합니다. 빌드/배포 파이프라인에서 재현성을 높이는 방법은 GitHub Actions 캐시가 안 먹을 때 속도 3배 올린 실전처럼 “관측과 고정”을 먼저 하는 접근이 도움이 됩니다.