Published on

OAuth2 PKCE에서 invalid_grant 뜰 때 7가지 점검

Authors

서드파티 IdP(Okta, Auth0, Cognito, Keycloak 등)든 사내 Authorization Server든, Authorization Code + PKCE 플로우에서 /token 호출이 invalid_grant로 떨어지면 체감 난이도가 확 올라갑니다. 이유는 간단합니다. invalid_grant는 “그랜트가 유효하지 않다”라는 포괄적 에러라서, 실제 원인은 코드 만료/재사용/리다이렉트 불일치/PKCE 검증 실패/클라이언트 불일치 등 여러 갈래로 흩어지기 때문입니다.

이 글은 “PKCE인데 왜 invalid_grant?” 상황에서 가장 자주 터지는 7가지 원인을, 확인 순서와 함께 정리합니다. (대부분은 서버 로그 한 줄과 요청 파라미터 비교만으로도 바로 잡힙니다.)

> 참고로 인증/권한 이슈는 메시지가 뭉뚱그려 나오는 경우가 많습니다. 비슷한 맥락으로 GitHub OIDC에서도 원인이 다른데 결과가 비슷하게 보일 때가 있는데, 그때는 이 글도 도움이 됩니다: GitHub Actions OIDC STS 실패 - InvalidIdentityToken

PKCE에서 invalid_grant가 의미하는 것

OAuth2 스펙에서 invalid_grant는 대체로 다음 범주를 포함합니다.

  • authorization_code존재하지 않거나
  • 만료되었거나
  • 이미 사용되었거나
  • redirect_uri / client / code_verifier 등과 매칭이 실패했거나
  • 정책상(테넌트/사용자/세션) 더 이상 유효하지 않게 되었거나

PKCE에서는 특히 code_verifiercode_challenge 검증이 추가되므로, 검증 실패도 invalid_grant로 뭉쳐서 나오는 IdP가 많습니다.

빠른 진단 체크리스트(요약)

아래 7가지를 위에서부터 순서대로 확인하면, 실무에서 대부분의 invalid_grant는 10분 안에 좁혀집니다.

  1. Authorization Code 만료(짧은 TTL, 지연/재시도)
  2. Code 재사용(중복 토큰 교환)
  3. redirect_uri 불일치(문자열 완전 일치 필요)
  4. code_verifier 불일치/변조(저장/복원/인코딩 문제)
  5. code_challenge_method 불일치(S256 vs plain)
  6. client_id/앱 설정 불일치(공용/비공용, confidential 혼용)
  7. 세션/정책으로 코드 무효화(로그아웃, MFA, nonce/state 꼬임)

이제 각각을 “어떻게 확인하고 어떻게 고치는지”로 들어가겠습니다.

1) Authorization Code가 만료됐다 (TTL/지연/시계 오차)

Authorization Code는 원래 매우 짧게 유효합니다(수십 초~수분). 모바일에서 네트워크가 느리거나, 백엔드에서 토큰 교환을 큐에 넣었다가 처리하거나, 프록시/게이트웨이에서 지연이 생기면 만료로 invalid_grant가 뜹니다.

확인 방법

  • Authorization Server 로그에서 code expired 류 메시지 확인
  • 프론트에서 /authorize로 code 받은 시각과 /token 호출 시각 차이 측정
  • 시스템 시간이 틀어져 있지 않은지(NTP) 확인

해결 방법

  • /token 교환을 즉시 수행(리다이렉트 직후)
  • 불필요한 재시도/큐잉 제거
  • 서버/클라이언트 시간 동기화

재현용 로그 포인트

  • iat(issued at)와 현재 시간 차이
  • AS가 기록하는 code_issue_time, code_expire_time

2) Authorization Code를 재사용했다 (중복 교환)

Authorization Code는 1회성입니다. 같은 code로 /token을 두 번 치면 보통 두 번째는 invalid_grant입니다.

실무에서 흔한 패턴:

  • 프론트가 리다이렉트 콜백을 두 번 실행(React StrictMode, 라우터 중복)
  • 백엔드가 타임아웃으로 판단해 재시도했지만, 사실 첫 요청은 성공했고 응답만 늦게 옴
  • 모바일에서 앱이 백그라운드/포그라운드 전환하며 콜백 핸들러가 중복 실행

확인 방법

  • /token 요청이 동일 code로 2번 이상 찍히는지(서버 access log)
  • 요청의 code 파라미터를 마스킹 후 해시로 저장해 중복 여부 확인

해결 방법

  • 콜백 핸들러에 단발성 가드(이미 처리한 code면 무시)
  • 백엔드 토큰 교환 로직에 idempotency 적용(예: code 해시를 키로 1회 처리)

예시: Node/Express에서 code 1회 처리 가드(간단 버전)

import crypto from "crypto";

const usedCodes = new Set(); // 실제로는 Redis 권장

function codeKey(code) {
  return crypto.createHash("sha256").update(code).digest("hex");
}

app.get("/oauth/callback", async (req, res) => {
  const { code } = req.query;
  const key = codeKey(String(code));

  if (usedCodes.has(key)) {
    return res.status(409).send("code already processed");
  }
  usedCodes.add(key);

  // 여기서 /token 교환
  res.send("ok");
});

3) redirect_uri가 1바이트라도 다르다 (완전 일치)

PKCE 여부와 무관하게, /authorize 요청에 넣은 redirect_uri/token 요청에 넣는 redirect_uri대부분의 IdP에서 완전 일치(exact match) 해야 합니다.

자주 틀리는 포인트:

  • trailing slash: https://app/callback vs https://app/callback/
  • URL 인코딩 차이: %2F 처리
  • 쿼리 포함 여부: .../callback?env=prod
  • http/https, 포트, 대소문자

확인 방법

  • /authorize/token에서 실제로 전송된 redirect_uri를 그대로 로깅
  • IdP 콘솔에 등록된 redirect URI와 비교

해결 방법

  • 앱에서 redirect URI를 상수화하고 두 요청에 동일 값 사용
  • 환경별 도메인/포트가 다르면 IdP에 모두 등록

예시: curl로 /token 요청 시 redirect_uri 포함

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"

4) code_verifier가 다르다 (저장/복원/인코딩 문제)

PKCE의 핵심은 code_verifier입니다. /authorize에서 만든 verifier로부터 challenge를 만들고, /token에서 같은 verifier를 제출해야 합니다.

invalid_grant를 만드는 대표 실수:

  • verifier를 로컬스토리지/세션에 저장했는데, 콜백 시점에 다른 탭/다른 세션이 열림
  • SPA 라우팅 중 state가 초기화되어 verifier가 사라짐
  • base64url이 아닌 base64를 사용(패딩 = 포함, +// 포함)
  • verifier 문자열에 개행/공백이 섞임

확인 방법

  • /authorize 직전에 생성한 code_verifier를 (개발 환경에서만) 콘솔/로그로 확인
  • 콜백에서 /token 요청에 실린 verifier와 동일한지 비교

해결 방법

  • verifier 저장소를 탭/세션에 안전하게 묶기(예: sessionStorage + state 키)
  • base64url 인코딩 구현을 검증된 라이브러리로 교체

예시: 브라우저에서 S256 code_challenge 생성(정석)

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/g, "");
}

async function pkceChallengeFromVerifier(verifier) {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64url(new Uint8Array(digest));
}

// verifier는 RFC 7636에 맞게 43~128 chars 권장
const verifier = crypto.randomUUID() + crypto.randomUUID();
const challenge = await pkceChallengeFromVerifier(verifier);
console.log({ verifier, challenge, method: "S256" });

5) code_challenge_method가 서버 설정과 다르다 (S256/ plain)

요즘 IdP는 보안상 S256만 허용하는 경우가 많습니다. 그런데 클라이언트가 plain으로 보내거나, 반대로 서버가 plain만 허용하는 특이 설정이면 검증에 실패하고 invalid_grant로 떨어질 수 있습니다.

확인 방법

  • /authorize 요청에 code_challenge_method=S256가 실제로 붙는지 확인
  • IdP 앱 설정에서 PKCE 정책 확인(허용 메서드)

해결 방법

  • 가능하면 항상 S256 사용
  • 라이브러리 기본값이 plain인지 확인 후 강제 설정

예시: /authorize 요청 예시

GET /authorize?
  response_type=code&
  client_id=...&
  redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback&
  scope=openid%20profile&
  state=...&
  code_challenge=...&
  code_challenge_method=S256

6) client_id(또는 클라이언트 타입) 불일치

PKCE는 주로 public client(SPA/모바일)에서 쓰지만, 백엔드가 끼어드는 구조(BFF)에서는 설정이 꼬이기 쉽습니다.

흔한 케이스:

  • /authorize는 SPA의 client_id로 받았는데 /token은 서버 앱의 client_id로 교환
  • IdP에서 앱을 confidential로 만들어 놓고 /token에서 client_secret을 요구하거나, 반대로 public인데 secret을 보내서 정책에 걸림
  • 멀티테넌트에서 잘못된 issuer/realm로 토큰 교환

확인 방법

  • /authorize/tokenclient_id가 동일한지 확인
  • IdP 콘솔에서 해당 client가 PKCE를 요구하는지/허용하는지 확인
  • issuer(/.well-known/openid-configuration)가 환경과 일치하는지 확인

해결 방법

  • 한 플로우에서 동일 client로 끝까지 처리
  • BFF 패턴이면 “서버가 code를 받는지, 브라우저가 code를 받는지”를 명확히 하고 구성 단순화

7) 세션/정책 변화로 code가 무효화됐다 (로그아웃, MFA, state 꼬임)

IdP 정책에 따라 다음 이벤트가 발생하면 발급된 code가 무효화될 수 있습니다.

  • 사용자가 로그인 후 바로 로그아웃/세션 종료
  • MFA 단계가 완료되지 않았는데 code 교환을 시도
  • 특정 위험 정책(디바이스/위치)으로 트랜잭션이 취소
  • state/nonce 검증 실패 후 앱이 잘못된 code를 교환(특히 여러 탭)

여기서도 결과는 invalid_grant로 뭉쳐 보일 수 있습니다.

확인 방법

  • IdP 이벤트 로그(사용자 세션, MFA, 정책 거부 사유)
  • 앱에서 state를 키로 verifier를 매핑했는지(다른 state의 verifier로 교환하면 실패)

해결 방법

  • state세션 키로 사용해 verifier/redirect_uri를 함께 저장
  • 콜백 처리 시 state 불일치면 즉시 중단(토큰 교환 시도 금지)

예시: state 기반으로 verifier 저장(브라우저)

// authorize 직전
const state = crypto.randomUUID();
const verifier = crypto.randomUUID() + crypto.randomUUID();
sessionStorage.setItem(`pkce:${state}`, verifier);

// callback에서
const params = new URLSearchParams(location.search);
const cbState = params.get("state");
const code = params.get("code");

const storedVerifier = sessionStorage.getItem(`pkce:${cbState}`);
if (!storedVerifier) {
  throw new Error("missing verifier for state (possible tab/session mixup)");
}
// storedVerifier로 /token 교환

실전 디버깅 팁: 요청/응답을 ‘비교 가능한 형태’로 남겨라

invalid_grant는 결국 “서버가 기대한 값과 실제 값이 다르다”로 귀결되는 경우가 많습니다. 그래서 디버깅의 핵심은 두 요청(/authorize, /token)의 상관관계(correlation) 를 남기는 것입니다.

  • state를 correlation id로 삼아 로그를 묶기
  • /authorize 시점에 생성한 code_verifier해시(원문은 노출 금지)를 저장
  • /token 요청 바디에서 redirect_uri, client_id, code(해시)를 저장

인증 문제는 네트워크/환경 변수도 영향을 줍니다. 특히 배포 환경에서만 재현된다면, 인프라 로그/권한 이슈처럼 “원인은 다른데 증상은 한 가지”로 나타날 수 있습니다. 그런 류의 트러블슈팅 접근은 이 글도 참고할 만합니다: systemd 서비스가 재부팅 후 안 뜰 때 12단계 점검

마무리: 가장 흔한 원인 Top 3

현장에서 체감상 빈도가 높은 순으로 꼽으면 보통 이렇습니다.

  1. redirect_uri 불일치(특히 슬래시/쿼리/포트)
  2. code_verifier 저장/복원 실패(탭/세션/인코딩)
  3. code 재사용(중복 콜백/재시도)

위 3개만 제대로 가드해도 invalid_grant의 70~80%는 사라집니다. 그래도 안 잡히면, IdP 이벤트 로그(정책/MFA/세션)와 Authorization Server의 PKCE 검증 로그를 반드시 확인하세요.