Published on

OAuth 2.0 PKCE invalid_grant 5분 디버깅

Authors

OAuth 2.0 Authorization Code + PKCE를 붙여서 구현하면, 토큰 교환 단계에서 종종 invalid_grant를 만납니다. 문제는 이 에러가 너무 포괄적이라서, 실제 원인이 code_verifier 불일치인지, redirect_uri 미스매치인지, 코드 재사용인지, 시계 오차인지 한 번에 감이 안 온다는 점입니다.

이 글은 “5분 안에” 원인을 좁히는 데 목적이 있습니다. 즉, 완벽한 이론보다 재현 가능한 로그/체크 포인트즉시 확인 가능한 분기에 집중합니다.

관련해서 더 넓은 원인 목록이 필요하면 OAuth2 PKCE에서 invalid_grant 나는 7가지도 함께 참고하세요. 콜백 단계에서 redirect_uri_mismatch가 뜬다면 토큰 교환 이전 문제이므로 OAuth 콜백 400 redirect_uri_mismatch 즉시 해결가 더 빠릅니다.


0) 5분 디버깅 목표: 원인을 3분류로 나누기

invalid_grant는 대개 아래 3가지 축 중 하나로 귀결됩니다.

  1. Authorization Code 자체가 유효하지 않음: 만료, 재사용, 다른 클라이언트/리다이렉트로 발급됨
  2. PKCE 검증 실패: code_verifier가 다르거나 변형됨, code_challenge_method 불일치
  3. 요청 파라미터 미스매치: redirect_uri가 토큰 요청에서 다름(또는 누락), 잘못된 grant_type 등

5분 디버깅은 “토큰 엔드포인트 요청 1건”을 기준으로, 위 분류를 빠르게 판정하는 과정입니다.


1) 1분: 토큰 요청을 한 줄로 고정(재현 가능한 형태)

먼저 실제 실패한 토큰 요청을 복제 가능한 curl로 고정하세요. 이 단계가 되면, 프론트/백엔드/모바일/프록시 등 복잡한 경로를 잠시 잊고 “토큰 엔드포인트 입력값”만 보게 됩니다.

아래는 일반적인 PKCE 토큰 교환 요청 예시입니다.

curl -sS -X POST "https://auth.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code=AUTHORIZATION_CODE" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=YOUR_CODE_VERIFIER"

체크포인트

  • 실패 케이스에서 반드시 아래 4가지를 로그로 남기세요(보안상 운영 로그는 마스킹 권장).
    • code (앞 6~10자만)
    • redirect_uri (전체)
    • code_verifier 길이와 해시(원문 대신)
    • 요청 시각(서버 기준)

code_verifier는 민감정보에 가깝습니다. 운영에서는 원문 대신 아래처럼 길이 + SHA-256만 남겨도 디버깅이 됩니다.

import crypto from "crypto";

export function maskVerifier(verifier) {
  const hash = crypto.createHash("sha256").update(verifier, "utf8").digest("hex");
  return { len: verifier.length, sha256: hash };
}

2) 2분: PKCE 값이 “정말 같은 것”인지 독립적으로 검증

PKCE에서 가장 흔한 실수는 “내가 보낸 code_verifier가 맞다”는 믿음입니다. 앱/브라우저/서버/리다이렉트 경로를 거치면서 공백, URL 인코딩, 문자열 정규화, 저장소 손상이 섞여 달라지는 일이 실제로 많습니다.

PKCE 규칙 빠른 점검

  • code_verifier 길이: 43~128
  • 허용 문자: unreserved 문자(대개 A-Z, a-z, 0-9, -, ., _, ~)
  • S256인 경우 code_challenge는:
    • BASE64URL( SHA256( code_verifier ) )
    • Base64가 아니라 Base64URL (패딩 = 제거, +-, /_)

로컬에서 code_challenge 재계산(독립 검증)

아래 Node.js 스니펫으로, 프론트에서 만들었다는 code_verifiercode_challenge를 재계산해 로그에 찍힌 code_challenge와 동일한지 확인합니다.

import crypto from "crypto";

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

export function pkceChallengeS256(verifier) {
  const hash = crypto.createHash("sha256").update(verifier, "utf8").digest();
  return base64url(hash);
}

// 사용 예
const verifier = process.env.CODE_VERIFIER;
console.log(pkceChallengeS256(verifier));

여기서 바로 갈리는 분기

  • 재계산한 code_challenge가 “처음 authorize 요청에 넣었던 code_challenge”와 다르다
    • 거의 확실히 verifier 저장/복원/인코딩 문제입니다.
  • 재계산한 값이 일치한다
    • PKCE 값 자체는 정상일 가능성이 높고, code/redirect_uri/재사용/만료 쪽으로 이동합니다.

3) 3분: redirect_uri 미스매치 여부를 “토큰 요청” 기준으로 확인

많은 개발자가 콜백이 정상으로 돌아오면 redirect_uri는 끝났다고 생각합니다. 하지만 일부 OAuth 서버는 토큰 교환 요청에서의 redirect_uri까지 authorization request와 “완전 일치”해야 합니다.

즉시 확인할 것

  • 토큰 요청에 redirect_uri를 넣는 구현인가? (넣지 않는 경우도 있는데, 서버 정책에 따라 실패)
  • authorize 요청의 redirect_uri와 토큰 요청의 redirect_uri문자열로 완전 동일한가?
    • https://app.example.com/callback vs https://app.example.com/callback/
    • 쿼리 포함 여부: callback?env=prod
    • 대소문자, 포트, 스킴(http vs https)

서버/프록시 환경에서 자주 생기는 함정

  • 프론트는 https로 인식하지만 백엔드가 내부에서 http로 조립
  • 리버스 프록시가 X-Forwarded-Proto를 전달하지 않아, 백엔드가 redirect_uri를 잘못 생성

Node/Express에서 스킴 추론을 안전하게 하려면(프록시 뒤라면) 대개 아래가 필요합니다.

import express from "express";

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

app.get("/oauth/start", (req, res) => {
  const baseUrl = `${req.protocol}://${req.get("host")}`;
  const redirectUri = `${baseUrl}/callback`;
  // redirectUri를 authorize 요청에 사용
  res.json({ redirectUri });
});

redirect_uri 문제는 콜백에서 redirect_uri_mismatch로 먼저 터지기도 하지만, 콜백은 통과했는데 토큰에서 invalid_grant로 뭉뚱그려지는 서버도 있습니다. 이 경우 위의 “문자열 완전 일치” 검사가 가장 빠릅니다.


4) 4분: Authorization Code 재사용/중복 교환 여부

Authorization Code는 1회성입니다. 같은 code로 토큰 교환을 두 번 시도하면 두 번째는 흔히 invalid_grant입니다.

재사용이 생기는 전형적인 패턴

  • 프론트에서 토큰 교환 요청을 두 번 날림
    • React Strict Mode 개발 환경에서 useEffect가 두 번 실행
    • 버튼 더블 클릭/중복 submit
  • 백엔드에서 재시도 로직이 무심코 붙어 있음
    • 네트워크 타임아웃을 “실패”로 간주하고 동일 code로 재전송
  • 콜백 라우트가 새로고침되며 동일 code로 다시 교환

프론트에서 중복 교환 방지 예시

let exchanging = false;

export async function exchangeOnce(params: URLSearchParams) {
  if (exchanging) return;
  exchanging = true;
  try {
    const code = params.get("code");
    if (!code) throw new Error("missing code");

    const res = await fetch("/api/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ code }),
    });

    if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
    return await res.json();
  } finally {
    exchanging = false;
  }
}

백엔드에서 멱등성 키로 막기(권장)

code를 키로 해서 “이미 교환 중/교환 완료” 상태를 짧게 캐시해도 효과가 큽니다.

  • Redis에 SETNX oauth:code:{codeHash} 1 EX 60
  • 성공/실패에 따라 상태 기록

이렇게 하면 “재시도”가 필요할 때도 같은 code로 무한 시도하는 상황을 막고, 원인 로그가 깨끗해집니다.


5) 5분: 만료/시간 오차(Clock Skew)와 서버 정책 확인

Authorization Code는 보통 만료가 매우 짧습니다(수십 초~수분). 특히 모바일에서 앱 전환이 길어지거나, 사용자 SSO 화면에서 오래 머물면 코드가 만료될 수 있습니다.

빠른 진단법

  • 콜백에서 code를 받은 시각과 토큰 교환 요청 시각의 차이를 로그로 남기기
  • 차이가 30초~수분을 넘는다면, 만료 가능성이 큼
const t0 = Date.now(); // code 수신 시각 저장
// ...
const t1 = Date.now(); // 토큰 교환 시각
console.log({ codeAgeMs: t1 - t0 });

또 다른 함정은 서버/컨테이너의 시간이 어긋난 경우입니다. OAuth 서버가 발급/검증 시각을 엄격히 보면, 수십 초의 오차로도 실패할 수 있습니다.

  • NTP 동기화 확인
  • 컨테이너 노드 시간 확인
  • 멀티 리전에서 시간/라우팅이 꼬이지 않는지 확인

실전 체크리스트(한 장 요약)

아래 순서대로 보면 “5분 안에” 대부분 좁혀집니다.

  1. 실패한 토큰 요청을 curl로 고정 (grant_type, client_id, code, redirect_uri, code_verifier)
  2. code_verifier 길이/허용문자 확인, 원문 대신 lensha256 로깅
  3. 로컬에서 code_challenge 재계산해 authorize 때 값과 비교
  4. redirect_uri가 authorize와 토큰에서 완전 동일한지 비교(슬래시, 쿼리, 스킴)
  5. 동일 code로 중복 교환이 일어나지 않는지(Strict Mode, 새로고침, 재시도) 추적
  6. code 수명(발급 후 경과 시간)과 서버 시간 동기화 확인

자주 쓰는 “원인별” 로그 포맷 제안

운영에서 재현이 어려울수록 로그가 전부입니다. 아래처럼 “한 줄 구조 로그”로 남기면, invalid_grant가 떠도 원인 분류가 빨라집니다.

{
  "event": "oauth_token_exchange_failed",
  "provider": "example",
  "clientId": "...",
  "redirectUri": "https://app.example.com/callback",
  "codePrefix": "ab12cd",
  "verifier": { "len": 64, "sha256": "..." },
  "codeAgeMs": 18422,
  "httpStatus": 400,
  "error": "invalid_grant"
}

이 로그가 쌓이면 다음이 쉬워집니다.

  • 특정 배포 이후 verifier.len이 갑자기 짧아짐(문자열 잘림)
  • 특정 도메인에서만 redirectUri가 달라짐(프록시 설정)
  • codeAgeMs가 특정 디바이스에서 유독 큼(앱 전환 UX)

마무리: invalid_grant는 “입력값 불일치”로 생각하면 빨라진다

PKCE에서 invalid_grant는 대개 “서버가 기대한 것”과 “내가 보낸 것”의 불일치입니다. 따라서 토큰 요청을 고정하고, PKCE를 독립 재계산하고, redirect_uri 완전 일치와 code 1회성을 확인하면 디버깅 시간이 급격히 줄어듭니다.

더 많은 케이스(모바일 딥링크, 멀티 탭, 저장소 이슈 등)를 폭넓게 보려면 OAuth2 PKCE에서 invalid_grant 나는 7가지에서 원인별로 확장해서 점검해보세요.