Published on

OAuth PKCE invalid_grant 해결 체크리스트

Authors

서드파티 OAuth 또는 사내 IdP에서 PKCE(Proof Key for Code Exchange)를 붙이는 순간, 가장 흔하게 마주치는 에러가 invalid_grant입니다. 문제는 이 에러가 너무 포괄적이라서 “인가 코드가 틀렸다”부터 “리다이렉트 URI가 1글자 다르다”, “code_verifier 길이가 스펙 밖이다”까지 전부 같은 이름으로 떨어진다는 점입니다.

이 글은 PKCE 환경에서 invalid_grant빠르게 재현하고, 로그로 원인을 좁히고, 재발 방지까지 하는 체크리스트 형태로 정리합니다.

참고로, 장애를 체크리스트로 쪼개서 추적하는 방식은 아래 글들과 같은 결로 접근하면 효율이 좋습니다.

invalid_grant가 의미하는 것(공통 분모)

OAuth 2.0에서 invalid_grant는 대체로 토큰 엔드포인트에서 “제출한 grant(인가 코드, 리프레시 토큰 등)가 유효하지 않다”를 뜻합니다. PKCE에서는 특히 다음 케이스가 많습니다.

  • 인가 코드(code)가 이미 사용됨(재사용)
  • 인가 코드가 만료됨
  • redirect_uri 불일치
  • client_id 불일치(또는 잘못된 client)
  • PKCE 검증 실패(code_verifier 또는 code_challenge 불일치)
  • 토큰 요청 형식 오류(파라미터 누락, 인코딩/전송 방식 오류)

이제부터는 “가장 흔하고, 확인이 쉬운 것부터” 순서대로 봅니다.

1) 인가 코드 재사용 여부(가장 흔함)

인가 코드 방식에서 code1회용입니다. 아래 상황에서 재사용이 쉽게 발생합니다.

  • 프론트가 새로고침되며 콜백 URL을 다시 처리
  • 모바일 WebView가 콜백을 두 번 로드
  • 백엔드가 토큰 교환 재시도를 무조건 수행(네트워크 타임아웃 후 재시도)
  • 멀티 인스턴스 환경에서 동일 콜백을 중복 처리(중복 요청)

체크

  • 콜백 핸들러에서 code한 번만 처리하도록 멱등성 키를 둡니다.
  • 토큰 교환 실패 시 재시도는 “같은 code로 재시도”가 아니라, 다시 authorize부터 시작해야 합니다.

예시: 콜백 멱등 처리(서버)

// pseudo: callback handler
authCallback(req, res) {
  const code = req.query.code;
  const state = req.query.state;

  // (1) state 검증 후
  // (2) code를 저장소에 "사용 처리" (원자적)
  const ok = redis.setnx(`oauth:code:used:${code}`, "1");
  if (!ok) {
    return res.status(409).send("code already used");
  }
  redis.expire(`oauth:code:used:${code}`, 300);

  // 토큰 교환
}

2) 인가 코드 만료(짧은 TTL)

IdP마다 다르지만 인가 코드는 보통 수십 초에서 수 분 내 만료됩니다. 특히 모바일에서 앱 전환, 네트워크 지연, 사용자가 동의 화면에서 오래 머무는 경우 만료가 잦습니다.

체크

  • IdP 설정에서 code TTL을 확인합니다.
  • 콜백을 받은 즉시 토큰 교환을 수행합니다.
  • 서버 시간이 크게 틀어져 있지 않은지(NTP) 확인합니다.

운영 팁

  • 토큰 교환 요청의 시작 시각과 code 발급 시각(가능하면)을 로그로 남겨 “만료”를 수치로 확인합니다.

3) redirect_uri 불일치(문자 단위로 동일해야 함)

토큰 교환 시 redirect_uri는 대개 authorize 요청 때와 완전히 동일해야 합니다. 다음 차이도 불일치로 처리될 수 있습니다.

  • http vs https
  • 도메인 대소문자
  • 경로의 슬래시 유무(예: .../callback vs .../callback/)
  • 쿼리스트링 포함 여부
  • 포트 포함 여부

체크

  • authorize 요청의 redirect_uri와 token 요청의 redirect_uri로그로 그대로 출력해서 비교합니다.
  • 환경별로 리다이렉트 URI가 바뀌는 경우(로컬, 스테이징, 프로덕션), IdP 등록값과 완벽히 매칭되는지 확인합니다.

예시: 토큰 요청 로그(민감정보 마스킹)

console.info("token_exchange", {
  redirect_uri: redirectUri,
  client_id: clientId,
  grant_type: "authorization_code",
  code_prefix: code?.slice(0, 6),
  verifier_len: codeVerifier?.length,
});

4) PKCE 파라미터 생성/전달 오류

PKCE에서 핵심은 code_challengecode_verifier가 스펙대로 생성되고, 토큰 교환 시 동일한 code_verifier가 전달되는 것입니다.

4-1) code_challenge_method 불일치

요즘 IdP는 S256을 권장하거나 강제합니다. 그런데 클라이언트가 plain으로 보내거나, method를 누락하면 실패할 수 있습니다.

  • authorize 요청: code_challenge_method=S256 + code_challenge=...
  • token 요청: code_verifier=...

4-2) code_verifier 길이/문자셋 위반

code_verifier는 일반적으로 길이 43~128이고 URL-safe 문자셋을 사용해야 합니다. 구현에서 흔한 실수:

  • 너무 짧은 랜덤 문자열
  • base64 결과에 = 패딩이 남음
  • + / 같은 문자가 섞인 base64(표준 base64) 사용

4-3) base64url 인코딩 실수

S256은 대개 다음 순서입니다.

  1. SHA-256(code_verifier)
  2. 결과를 base64url 인코딩(패딩 제거)

예시: Node.js에서 올바른 PKCE 생성

import crypto from "crypto";

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

export function createPkcePair() {
  const codeVerifier = base64url(crypto.randomBytes(32)); // 보통 43자 이상 확보
  const challenge = base64url(
    crypto.createHash("sha256").update(codeVerifier).digest()
  );

  return {
    codeVerifier,
    codeChallenge: challenge,
    codeChallengeMethod: "S256",
  };
}

체크

  • authorize 요청에 보낸 code_challenge를 저장해두고, 서버(또는 디버그 도구)에서 code_verifier로 다시 계산해 동일한 값이 나오는지 확인합니다.
  • 모바일/웹에서 code_verifier를 저장하는 위치가 안전하고 안정적인지 확인합니다(아래 5번 참고).

5) code_verifier 저장/복원 문제(특히 SPA, 모바일)

PKCE는 토큰 교환 시점에 code_verifier가 반드시 필요합니다. 그런데 다음 상황에서 code_verifier를 잃어버립니다.

  • SPA에서 메모리 변수에만 저장했다가 리다이렉트로 초기화
  • iOS/Android에서 프로세스가 죽었다가 복원되며 상태 유실
  • 쿠키 SameSite 정책 때문에 세션이 유지되지 않음

체크

  • 리다이렉트 전후로 유지되는 저장소를 사용합니다.
    • 웹: sessionStorage가 일반적(탭 단위)
    • 서버 연동: 서버 세션에 state 키로 묶어서 저장
  • state를 키로 code_verifier를 찾는 구조로 만듭니다.

예시: state 기반으로 verifier 매핑(서버)

# pseudo: before redirect to authorize
state = generate_state()
store.set(f"pkce:{state}", code_verifier, ttl=600)

# callback
code_verifier = store.get(f"pkce:{state}")
if not code_verifier:
    raise Exception("missing code_verifier for state")

6) state 검증 실패를 invalid_grant로 오해하는 경우

일부 구현은 state 불일치나 누락을 자체적으로 처리하다가, 결과적으로 토큰 교환 단계에서 엉뚱한 code_verifier를 붙여 보내 invalid_grant가 납니다.

체크

  • 콜백에서 state가 없거나 매칭되지 않으면 토큰 교환을 시도하지 말고 즉시 실패 처리합니다.
  • state는 CSRF 방어뿐 아니라 “이 요청의 PKCE 재료를 찾는 키”로도 쓰입니다.

7) 토큰 엔드포인트 요청 형식 오류

IdP는 토큰 요청에서 다음을 엄격히 요구하는 경우가 많습니다.

  • Content-Typeapplication/x-www-form-urlencoded
  • 바디는 form 인코딩
  • 파라미터 이름 정확히 일치(code_verifier, redirect_uri 등)

JSON으로 보내거나, 쿼리스트링으로 보내면 invalid_grant 또는 invalid_request로 뭉뚱그려 떨어질 수 있습니다.

예시: curl로 정상 토큰 교환

아래 예시는 S256 PKCE 기준입니다.

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

체크

  • 라이브러리가 자동으로 JSON 전송하지 않는지 확인합니다.
  • 프록시/게이트웨이가 본문을 변형하지 않는지 확인합니다.

8) client_id 또는 앱 설정 불일치

환경별로 client가 여러 개면(로컬용, 스테이징용, 프로덕션용) 다음이 흔합니다.

  • authorize는 A client로 했는데 token은 B client로 교환
  • 앱 설정에서 PKCE 필수인데 클라이언트가 PKCE 없이 시도
  • confidential client인데 secret을 요구하거나, 반대로 public client인데 secret을 보내서 거부

체크

  • authorize 요청과 token 요청에서 client_id가 동일한지 로그로 검증합니다.
  • IdP 콘솔에서 해당 client가 PKCE를 요구하는지, redirect URI가 등록되어 있는지 확인합니다.

9) 여러 탭/동시 로그인으로 state와 verifier가 꼬임

사용자가 로그인 버튼을 연속 클릭하거나 탭을 여러 개 열면, 마지막에 생성한 statecode_verifier가 저장소를 덮어써서 이전 콜백이 도착했을 때 매칭이 깨집니다.

체크

  • 저장소 키를 “고정 키”로 두지 말고 state별로 분리합니다.
  • UI에서 로그인 시작 시 버튼을 비활성화하거나, 진행 중 세션을 표시합니다.

10) 네트워크/프록시 계층에서 파라미터 손실

WAF, API Gateway, 프록시가 특정 파라미터를 필터링하거나 길이 제한을 걸면 code_verifier가 잘리거나 누락될 수 있습니다.

체크

  • 토큰 요청이 실제로 IdP에 어떤 바디로 전달되는지(프록시 이후) 확인합니다.
  • 요청 바디 길이 제한, 특정 필드 차단 정책이 있는지 확인합니다.

11) 진단을 빠르게 만드는 로그/계측 포인트

invalid_grant는 IdP 로그 접근 권한이 없으면 더 어렵습니다. 그래서 애플리케이션 측에서 아래를 남겨두면 평균 해결 시간이 크게 줄어듭니다.

  • authorize 요청 생성 시점
    • client_id, redirect_uri, scope, state(전체는 저장하되 로그는 prefix), code_challenge_method, code_challenge(prefix)
  • 콜백 수신 시점
    • state 매칭 성공 여부
    • code prefix
    • code_verifier 존재 여부, 길이
  • 토큰 교환 요청 시점
    • redirect_uri, client_id, grant_type, verifier_len
  • 토큰 교환 응답
    • HTTP status, error, error_description(있다면)

민감정보는 원문 그대로 로그로 남기지 말고 prefix 또는 해시를 추천합니다.

예시: verifier 해시로 상관관계 추적

// pseudo
verifierHash := sha256hex(codeVerifier)[:12]
log.Info("pkce", "state", state[:8], "verifier_hash", verifierHash, "verifier_len", len(codeVerifier))

12) 재현용 최소 시나리오(문제 분리)

문제가 복잡할수록 “내 앱”을 의심하기 전에, 아래처럼 최소 재현으로 분리하면 원인이 선명해집니다.

  1. 브라우저에서 authorize URL을 직접 열어 콜백으로 code를 받는다
  2. 같은 redirect_uri, 같은 client_id, 같은 code_verifiercurl 토큰 교환을 해본다
  3. curl이 성공하면 앱 구현(저장/전달/인코딩) 문제일 확률이 높다
  4. curl도 실패하면 IdP 설정(redirect URI 등록, PKCE 정책, client 타입) 문제일 확률이 높다

최종 체크리스트(요약)

아래 항목을 위에서 아래로 순서대로 확인하면, 대부분의 invalid_grant는 30분 내에 원인을 특정할 수 있습니다.

  • code를 재사용하고 있지 않은가(콜백 중복 처리, 재시도)
  • code가 만료되지 않았는가(지연, 사용자 체류, 서버 시간)
  • authorize와 token의 redirect_uri가 문자 단위로 동일한가
  • code_challenge_methodS256이고, 생성 로직이 base64url 규칙을 지키는가
  • code_verifier 길이와 문자셋이 스펙 범위인가
  • 리다이렉트 전후로 code_verifier를 안정적으로 저장/복원하는가
  • state 검증 실패 시 토큰 교환을 시도하지 않는가
  • 토큰 요청이 application/x-www-form-urlencoded로 전송되는가
  • client_id가 authorize와 token에서 동일한가(환경 혼선)
  • 프록시/WAF가 파라미터를 누락/절단하지 않는가

PKCE의 invalid_grant는 “PKCE가 어렵다”기보다, 대개 요청 간 상태를 안정적으로 이어주는 설계완전 일치 문자열 비교(redirect URI), 그리고 정확한 인코딩에서 갈립니다. 위 체크리스트대로 로그를 정리해두면, 다음 번엔 같은 증상이 나와도 원인 파악이 훨씬 빨라집니다.