Published on

OAuth 2.1 PKCE invalid_grant 해결 12가지

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, 인증 서버의 토큰 엔드포인트에서 invalid_grant 한 줄로 모든 것이 끝나는 순간을 자주 만납니다. 특히 OAuth 2.1에서 PKCE를 기본으로 쓰는 흐름에서는, Authorization Code 자체는 정상 발급되었는데 토큰 교환에서 실패하는 케이스가 많습니다.

invalid_grant는 표준적으로 “그랜트가 유효하지 않다”는 매우 넓은 의미라서, 서버 구현체마다 원인 메시지를 숨기거나 로그로만 남기기도 합니다. 이 글은 PKCE 기반 Authorization Code 흐름에서 invalid_grant가 나는 대표 원인 12가지를, 실무에서 바로 점검할 수 있게 체크리스트 형태로 정리합니다.

관련해서 인증 실패가 토큰 검증 단계(JWKS 캐시, 키 회전)에서 터지는 경우도 많습니다. 그 케이스는 별도 글인 Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응도 함께 참고하면 디버깅 시간이 줄어듭니다.

전제: PKCE 토큰 교환 요청이 정확히 어떻게 생겼나

Authorization Code + PKCE에서 토큰 교환은 대략 아래 형태입니다.

curl -X POST "https://auth.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=client_123" \
  -d "code=SplxlOBeZQQYbYS6WxSbIA" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=Zg3v...very-long-random...p9"

여기서 하나라도 서버가 기대하는 값과 다르면 invalid_grant로 뭉뚱그려 떨어질 수 있습니다.

1) redirect_uri가 Authorization 요청과 1바이트라도 다름

가장 흔한 원인입니다. Authorization 요청에서 사용한 redirect_uri와 토큰 교환 요청의 redirect_uri는 “완전 일치”를 요구하는 서버가 많습니다.

체크 포인트

  • 스킴 http vs https
  • 호스트 app.example.com vs www.app.example.com
  • 포트 :3000 포함 여부
  • 경로 트레일링 슬래시 /callback vs /callback/
  • 쿼리 포함 여부

재현 예시

# Authorization 때는 /callback
# Token 교환 때는 /callback/ 로 보내면 일부 IdP에서 invalid_grant
-d "redirect_uri=https://app.example.com/callback/"

해결

  • Authorization 요청에 사용한 redirect_uri를 그대로 저장해 두고 토큰 교환에 재사용
  • 서버 등록된 redirect URI 목록과 완전 일치하도록 고정

2) Authorization Code를 두 번 사용함(재사용 또는 동시 요청)

Authorization Code는 1회성입니다. 프론트에서 토큰 교환을 두 번 시도하거나, 백엔드와 프론트가 동시에 교환하면 두 번째 요청은 invalid_grant가 납니다.

자주 생기는 패턴

  • 콜백 페이지에서 useEffect가 두 번 실행(React Strict Mode 개발 환경)
  • 네트워크 재시도 로직이 멱등하지 않음
  • 모바일에서 딥링크 처리 중 중복 호출

프론트 중복 방지 예시(React)

let exchanging = false;

export async function exchangeOnce(params: URLSearchParams) {
  if (exchanging) return;
  exchanging = true;

  try {
    // token exchange call
  } finally {
    exchanging = false;
  }
}

3) Authorization Code 만료(짧은 TTL)

코드 TTL은 생각보다 짧습니다(30초~수분). 특히 모바일 네트워크, 앱 전환, 사용자 지연으로 토큰 교환이 늦어지면 만료로 invalid_grant가 납니다.

체크 포인트

  • 콜백 수신부터 토큰 교환까지 걸린 시간 측정
  • 서버 로그에서 code 발급 시각과 교환 시각 비교

해결

  • 콜백을 받자마자 서버에서 즉시 교환(백엔드 주도)
  • 사용자 상호작용 이후 교환 같은 구조 피하기

4) code_verifier 길이/문자셋 규격 위반

PKCE code_verifier는 RFC 7636에서 길이 43~128, 문자셋이 제한됩니다. 구현체가 엄격하면 규격 위반을 invalid_grant로 처리합니다.

Node.js에서 안전한 생성 예시

import crypto from "crypto";

export function generateCodeVerifier() {
  // 32 bytes -> base64url로 대략 43자 이상
  return crypto.randomBytes(32).toString("base64url");
}

체크 포인트

  • 길이가 43 미만이거나 128 초과
  • +, /, = 같은 base64 문자가 포함(서버가 base64url 기대)

5) code_challenge_method 불일치 또는 누락

Authorization 요청에서 code_challenge_method=S256로 보냈는데, 서버가 plain으로 저장했거나 클라이언트가 실수로 plain을 보내는 경우가 있습니다.

Authorization 요청 예시

https://auth.example.com/authorize?response_type=code&client_id=client_123&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&code_challenge=...&code_challenge_method=S256

해결

  • 가능하면 항상 S256 사용
  • IdP 설정에서 PKCE 강제 옵션이 있는지 확인

6) code_challenge 계산이 base64url이 아니라 base64

S256 방식은 SHA-256(code_verifier)를 base64url로 인코딩해야 합니다. 여기서 base64와 base64url을 혼동하면 서버 검증이 실패해 invalid_grant가 납니다.

정확한 S256 계산 예시(Node.js)

import crypto from "crypto";

export function toCodeChallenge(verifier) {
  const hash = crypto.createHash("sha256").update(verifier).digest();
  // base64url 인코딩이 핵심
  return Buffer.from(hash).toString("base64url");
}

체크 포인트

  • 결과에 +, /, =가 남아 있으면 base64일 가능성

7) 토큰 교환 요청의 Content-Type 또는 바디 인코딩 오류

토큰 엔드포인트는 대개 application/x-www-form-urlencoded를 기대합니다. JSON으로 보내거나, form 인코딩이 깨지면 서버가 invalid_grant 또는 invalid_request로 응답할 수 있습니다.

Axios 예시(올바른 form 인코딩)

import axios from "axios";

const body = new URLSearchParams({
  grant_type: "authorization_code",
  client_id: "client_123",
  code: authCode,
  redirect_uri: "https://app.example.com/callback",
  code_verifier: verifier,
});

const res = await axios.post("https://auth.example.com/oauth/token", body, {
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
});

체크 포인트

  • 프록시/게이트웨이가 바디를 변환하거나 잘라먹지 않는지
  • code_verifier에 특수문자가 있을 때 URL 인코딩이 깨지지 않는지

8) 클라이언트 타입 혼동: Public 클라이언트에 client_secret을 보내거나 반대

PKCE는 주로 Public 클라이언트(브라우저 SPA, 모바일)에서 사용합니다. 그런데 토큰 교환에서 client_secret을 보내면, 서버 정책에 따라 거부되거나 예상과 다른 클라이언트로 매칭되어 invalid_grant가 날 수 있습니다.

체크 포인트

  • SPA인데 client_secret을 프론트에 넣고 있지 않은지(보안 사고)
  • Confidential 클라이언트인데 인증 방식이 누락되지 않았는지

해결

  • SPA는 PKCE + client_id만으로 교환(서버 설정 확인)
  • Confidential 클라이언트는 client_secret_basic 또는 client_secret_post를 서버 요구대로 맞춤

9) Authorization 요청의 client_id와 토큰 교환의 client_id 불일치

멀티 테넌트, 환경 분리(dev/stg/prod)에서 자주 발생합니다.

체크 포인트

  • Authorization URL을 만드는 프론트 설정과 토큰 교환을 하는 백엔드 설정이 같은지
  • 모바일 앱 빌드별로 client 설정이 섞이지 않았는지

해결

  • 콜백 요청에 포함된 issuer 또는 환경 정보를 기준으로, 같은 환경의 client 설정을 선택

10) Authorization Server의 시간 오차 또는 클러스터 노드 간 세션 불일치

일부 IdP는 Authorization Code를 노드 로컬 스토리지나 세션에 저장합니다. 로드밸런서 뒤에서 세션 스티키가 없거나, 노드 간 공유가 안 되면 “발급한 노드”와 “교환을 처리한 노드”가 달라져 invalid_grant가 발생할 수 있습니다.

체크 포인트

  • Authorization 엔드포인트와 Token 엔드포인트가 같은 클러스터인지
  • 스티키 세션 필요 여부
  • 노드 간 공유 저장소(예: Redis) 구성 여부
  • NTP 동기화 상태

해결

  • 코드 저장을 중앙 저장소로
  • 스티키 세션 적용(가능하면 근본 해결은 공유 저장)

네트워크 타임아웃이나 중간 장애로 재시도가 꼬이며 증상이 확대되는 경우가 많습니다. 인프라 관점의 타임아웃/재시도 설계는 EKS Pod→ElastiCache Redis 10분 타임아웃 진단법 같은 글의 접근(관측 지점 분리, 타임라인 복원)이 그대로 도움이 됩니다.

11) Authorization Code가 다른 redirect_uri로 발급됨(프록시 환경에서 원본 URL 혼동)

리버스 프록시(ALB, Nginx) 뒤에서 X-Forwarded-Proto, X-Forwarded-Host 처리가 잘못되면, 서버가 인지하는 외부 URL과 실제 브라우저 URL이 달라집니다. 그 결과 Authorization 요청은 A URL로 나갔는데, 서버는 B URL로 기록하거나, 토큰 교환에서 redirect mismatch로 invalid_grant가 납니다.

체크 포인트

  • 앱이 생성하는 redirect URI가 내부 도메인/내부 스킴을 쓰고 있지 않은지
  • 프록시가 X-Forwarded-*를 전달하는지
  • 프레임워크의 trust proxy 설정(Express 등)

Express 예시

import express from "express";

const app = express();
app.set("trust proxy", true);

12) Authorization 요청 파라미터가 서버 정책에 의해 정규화되거나 거부됨

일부 IdP는 Authorization 요청에서 특정 파라미터 조합을 강제합니다. 예를 들어

  • response_type=code만 허용
  • 특정 scope가 빠지면 code는 나오지만 토큰 교환에서 실패 처리
  • audience 또는 리소스 파라미터가 불일치

체크 포인트

  • Authorization 요청과 Token 요청의 파라미터를 “원문 그대로” 로깅
  • IdP 콘솔에서 앱 정책(Allowed scopes, audiences, redirect URI, PKCE required)을 재확인

해결

  • 최소 파라미터로 시작해 하나씩 추가하며 문제 파라미터를 찾기
  • 서버 로그에서 “정책 위반”이 invalid_grant로 매핑되는지 확인

디버깅을 빠르게 만드는 실전 로깅 템플릿

invalid_grant는 클라이언트와 서버 로그를 한 타임라인으로 묶어야 빨리 끝납니다. 아래 항목은 민감정보를 마스킹하되, 비교 가능한 형태로 남기는 것을 권장합니다.

  • Authorization 요청 시각, state(해시 처리), redirect_uri, code_challenge_method, code_challenge 앞 8자
  • 콜백 수신 시각, code 앞 8자, state 일치 여부
  • 토큰 교환 시각, redirect_uri, client_id, code_verifier 길이
  • 토큰 엔드포인트 응답 전문(가능하면 서버 측 correlation id)

Node.js 예시(마스킹 로깅)

function mask(s, keep = 8) {
  if (!s) return s;
  return s.slice(0, keep) + "...";
}

console.log({
  t: new Date().toISOString(),
  client_id,
  redirect_uri,
  code: mask(code),
  verifier_len: code_verifier?.length,
  challenge: mask(code_challenge),
});

정리: invalid_grant는 “불일치”의 다른 이름

PKCE에서 invalid_grant는 결국 아래 3종 불일치로 수렴하는 경우가 많습니다.

  • 값 불일치: redirect_uri, client_id, code_verifiercode_challenge
  • 시점 불일치: code 만료, 중복 교환, 재시도/동시성
  • 환경 불일치: 프록시로 인한 외부 URL 인지 오류, 클러스터 세션/스토리지 분리

위 12가지를 순서대로 체크하면, 대부분은 30분 안에 원인을 특정할 수 있습니다. 특히 redirect_uri 완전 일치와 code_verifier 생성 및 S256 계산(base64url)을 먼저 고정하면, PKCE 관련 invalid_grant의 절반 이상이 사라집니다.