Published on

OAuth2 PKCE invalid_grant 원인과 해결 체크리스트

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, 토큰 교환 단계에서 invalid_grant를 마주치는 경우가 많습니다. 특히 Authorization Code + PKCE 조합에서는 "Authorization Server가 기대한 값"과 "클라이언트가 보낸 값" 중 하나라도 어긋나면 대부분 invalid_grant로 뭉뚱그려 떨어집니다.

이 글은 PKCE 기반 OAuth2 플로우에서 invalid_grant가 발생하는 대표 원인, 빠른 진단 순서, 그리고 프론트엔드/백엔드 구현에서 자주 하는 실수를 코드와 함께 정리합니다.

관련해서 인증/인가에서 흔히 섞이는 403 이슈는 아래 글도 참고하면 좋습니다.

invalid_grant가 의미하는 것

invalid_grant는 OAuth2 스펙에서 "제공된 grant(예: authorization code)가 유효하지 않다"는 범주의 오류입니다. Authorization Code + PKCE에서는 보통 아래 중 하나입니다.

  • authorization code 자체가 잘못됨: 만료, 이미 사용됨, 다른 client에 속함
  • redirect_uri가 authorization 요청과 token 요청에서 일치하지 않음
  • PKCE 검증 실패: code_verifier가 다르거나 형식/인코딩이 잘못됨
  • 서버측 정책: code 수명 짧음, 시계 오차, 세션/쿠키 요구 등

중요한 점은, 많은 IdP가 보안상 상세 원인을 응답에 노출하지 않고 무조건 invalid_grant로만 반환한다는 것입니다. 그래서 "요청 파라미터 3종 세트"를 먼저 의심해야 합니다.

  • code
  • redirect_uri
  • code_verifier

가장 흔한 원인 TOP 7 (체크리스트)

1) code_verifier 불일치 (저장/전달 문제)

가장 흔합니다. 보통은 아래 패턴에서 터집니다.

  • 로그인 시작 시 생성한 code_verifier를 브라우저 새로고침/탭 이동으로 잃어버림
  • SPA에서 메모리에만 저장해 두었다가 콜백 라우트 진입 시 초기화됨
  • 백엔드가 토큰 교환을 하는데, 프론트에서 만든 code_verifier를 백엔드로 전달하지 않음

해결:

  • code_verifier는 "authorization 요청을 시작한 주체"가 "token 교환을 하는 주체"까지 동일하게 가지고 있어야 합니다.
  • SPA라면 세션 단위로 sessionStorage에 저장하고, 콜백 처리 후 즉시 삭제하세요.

2) code_challenge 생성 로직(인코딩) 오류

PKCE S256에서는 다음이 정확해야 합니다.

  • code_challengeBASE64URL(SHA256(ASCII(code_verifier)))
  • Base64가 아니라 Base64URL이어야 함
  • 패딩 = 제거
  • +-, /_로 치환

아래처럼 "일반 Base64"를 그대로 보내면 서버는 다른 값을 계산하게 되어 PKCE 검증이 실패합니다.

3) redirect_uri 완전 불일치

Authorization 요청에 넣은 redirect_uri와 token 요청에 넣는 redirect_uri가 바이트 단위로 동일해야 하는 IdP가 많습니다.

자주 틀리는 포인트:

  • 슬래시 유무: https://app.example.com/callback vs https://app.example.com/callback/
  • 쿼리 포함 여부: .../callback vs .../callback?foo=bar
  • URL 인코딩 차이
  • 프록시/로드밸런서 뒤에서 외부 도메인과 내부 도메인이 달라짐

해결:

  • redirect URI를 환경별로 하나로 고정하고, authorization 요청과 token 요청에서 동일한 상수를 사용하세요.
  • 백엔드가 토큰 교환을 한다면, X-Forwarded-Proto, X-Forwarded-Host 처리로 외부 기준 URL을 정확히 만들도록 설정합니다.

4) authorization code 재사용(중복 교환)

authorization code는 1회용입니다. 다음 상황에서 "중복 교환"이 생깁니다.

  • 콜백 라우트가 두 번 실행됨(React Strict Mode, 이중 렌더/이중 effect)
  • 사용자 더블클릭/뒤로가기/새로고침
  • 백엔드에서 재시도 로직이 무조건 재전송

해결:

  • 콜백 처리 로직에 "1회 처리" 가드를 둡니다.
  • 서버는 code를 교환한 뒤 즉시 세션에 "처리됨"을 기록하고 재요청을 막습니다.

분산 환경에서 중복 실행을 막는 사고방식은 아래 글의 멱등성/중복 방지 접근이 참고가 됩니다.

5) code 만료(토큰 교환 지연)

IdP에 따라 authorization code 수명이 매우 짧습니다(예: 30초~2분). 모바일에서 앱 전환을 오래 하거나, 네트워크 재시도 때문에 늦어지면 만료로 invalid_grant가 납니다.

해결:

  • 콜백 수신 즉시 token 교환을 수행
  • 불필요한 API 호출/동기 작업을 콜백 처리 전에 넣지 않기
  • 서버와 클라이언트의 시간 오차를 줄이기(특히 서버 시간)

6) 다른 클라이언트로 시작한 code를 다른 클라이언트로 교환

예를 들어 아래처럼 섞이면 실패합니다.

  • 프론트에서 A client_id로 authorize 요청
  • 백엔드에서 B client_id로 token 요청

해결:

  • authorize와 token 교환이 동일 client_id 컨텍스트에서 일어나도록 구성
  • 여러 앱/환경이 있다면 callback URL과 client_id 매핑을 명확히 분리

7) state/nonce 검증 실패를 invalid_grant로 뭉개는 IdP

일부 IdP는 state나 OIDC nonce 검증 실패를 invalid_grant로 반환하기도 합니다.

해결:

  • state는 CSRF 방어용으로 필수 저장/검증
  • nonce는 ID Token 검증에 필요(특히 SPA)

빠른 진단 순서 (실무용)

아래 순서대로 보면 대부분 10분 내에 원인을 좁힐 수 있습니다.

  1. 토큰 요청 전문을 로깅한다(민감정보 마스킹 필수)
    • grant_type, client_id, redirect_uri(있다면), code 길이, code_verifier 길이
  2. authorization 요청 시 사용한 redirect_uri 문자열과 token 요청의 redirect_uri를 그대로 비교한다
  3. code_verifier가 "생성한 그 값"인지 확인한다
    • 콜백 화면에서 새로 생성하고 있지 않은지
    • 저장소(sessionStorage 등)에서 읽어오는지
  4. 같은 code로 두 번 요청이 나가고 있지 않은지 확인한다
  5. code 수명/만료 정책 확인(서버 로그 또는 IdP 설정)

PKCE 생성/검증: 올바른 코드 예제

브라우저(Web Crypto)에서 code_verifier / code_challenge 만들기

아래 예제는 S256 기준으로 Base64URL을 정확히 생성합니다.

// pkce.ts
function base64UrlEncode(bytes: Uint8Array): string {
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  const base64 = btoa(binary);
  return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

export function generateCodeVerifier(byteLength = 32): string {
  const bytes = new Uint8Array(byteLength);
  crypto.getRandomValues(bytes);
  return base64UrlEncode(bytes);
}

export async function generateCodeChallenge(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(new Uint8Array(digest));
}

사용 예:

const verifier = generateCodeVerifier();
sessionStorage.setItem("pkce_verifier", verifier);

const challenge = await generateCodeChallenge(verifier);

const params = new URLSearchParams({
  response_type: "code",
  client_id: "your_client_id",
  redirect_uri: "https://app.example.com/oauth/callback",
  code_challenge: challenge,
  code_challenge_method: "S256",
  state: crypto.randomUUID()
});

location.href = `https://idp.example.com/oauth/authorize?${params.toString()}`;

콜백에서 token 교환 요청 만들기 (중요 포인트 포함)

// callback.ts
const url = new URL(location.href);
const code = url.searchParams.get("code");

if (!code) throw new Error("Missing code");

const verifier = sessionStorage.getItem("pkce_verifier");
if (!verifier) throw new Error("Missing code_verifier (lost sessionStorage?)");

// 1회용 처리: 먼저 지워서 중복 실행 시도 자체를 줄임
sessionStorage.removeItem("pkce_verifier");

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

const resp = await fetch("https://idp.example.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body
});

if (!resp.ok) {
  const text = await resp.text();
  throw new Error(`Token exchange failed: ${resp.status} ${text}`);
}

const token = await resp.json();
console.log(token);

핵심은 아래 3가지입니다.

  • redirect_uri는 authorize와 token에서 동일
  • code_verifier는 "처음 만든 값"을 그대로 사용
  • 중복 실행을 방지(삭제 또는 처리 플래그)

백엔드가 token 교환을 하는 경우의 함정

보안상 SPA가 직접 token endpoint를 치지 않고, 백엔드가 code를 받아 교환하는 구조를 많이 씁니다. 이때 PKCE가 꼬이는 전형적인 이유는 "verifier가 프론트에만 있다"는 점입니다.

가능한 패턴은 2가지입니다.

  1. 프론트가 code_verifier를 백엔드로 안전하게 전달하고, 백엔드가 token 교환
  • 이때 verifier 전달은 반드시 HTTPS + 동일 세션 컨텍스트(쿠키)로 묶어야 합니다.
  1. 애초에 백엔드에서 authorize 시작부터 담당
  • 백엔드가 verifier를 생성하고 서버 세션/캐시에 저장
  • authorize redirect를 백엔드가 구성
  • 콜백에서 code를 받으면 저장된 verifier로 교환

Node.js(Express)에서 verifier를 서버 세션에 저장하는 예

import express from "express";
import session from "express-session";
import crypto from "crypto";

const app = express();
app.use(session({
  secret: "replace-me",
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: "lax", secure: true }
}));

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

function sha256Base64Url(str) {
  const hash = crypto.createHash("sha256").update(str, "ascii").digest();
  return base64Url(hash);
}

app.get("/auth/start", (req, res) => {
  const verifier = base64Url(crypto.randomBytes(32));
  const challenge = sha256Base64Url(verifier);

  req.session.pkceVerifier = verifier;

  const redirectUri = "https://app.example.com/auth/callback";
  const params = new URLSearchParams({
    response_type: "code",
    client_id: process.env.OAUTH_CLIENT_ID,
    redirect_uri: redirectUri,
    code_challenge: challenge,
    code_challenge_method: "S256",
    state: crypto.randomUUID()
  });

  res.redirect(`https://idp.example.com/oauth/authorize?${params.toString()}`);
});

이 구조에서 invalid_grant가 난다면 가장 먼저 확인할 것은 세션이 콜백까지 유지되는지(쿠키 SameSite, 도메인, HTTPS)입니다. 콜백에서 세션이 끊기면 verifier를 못 찾고, 결국 잘못된 verifier로 교환하거나 빈 값으로 보내게 됩니다.

로그로 원인 좁히는 방법(민감정보 주의)

invalid_grant는 파라미터 검증 실패가 많아서, "어떤 값이 달랐는지"를 서버 로그에서 재구성할 수 있어야 합니다.

권장 로깅(마스킹):

  • client_id
  • redirect_uri
  • code는 전체를 남기지 말고 길이와 앞 6자만
  • code_verifier도 전체를 남기지 말고 길이만
  • 요청 시각, 응답 시각(지연)

예시:

oauth.token_exchange request_id=abc123 client_id=web redirect_uri=https://app.example.com/oauth/callback code_prefix=SplxlO code_len=120 verifier_len=43
oauth.token_exchange response_id=abc123 status=400 error=invalid_grant elapsed_ms=842

이 정도만 있어도 아래를 판단할 수 있습니다.

  • verifier가 비정상적으로 짧거나(예: 0, 10) 저장/전달 실패
  • elapsed가 너무 길어 만료 가능성
  • redirect_uri가 환경에 따라 흔들리는지

자주 묻는 케이스별 처방

React/Next.js에서 콜백 effect가 두 번 실행돼요

개발 모드에서 React Strict Mode는 일부 effect를 두 번 호출해 부작용을 잡습니다. 콜백 페이지에서 token 교환을 effect로 돌리면 같은 code로 두 번 교환 시도해서 invalid_grant가 날 수 있습니다.

대응:

  • 토큰 교환을 서버 라우트로 옮기거나
  • 클라이언트에서 1회 처리 플래그를 둡니다.
const ran = sessionStorage.getItem("oauth_callback_ran");
if (ran) return;
sessionStorage.setItem("oauth_callback_ran", "1");

RSC/클라이언트 경계 때문에 번들이 커지거나 구조가 복잡해지는 문제는 아래 글도 같이 보면 설계에 도움이 됩니다.

모바일 앱에서만 간헐적으로 invalid_grant가 나요

  • 앱 전환으로 콜백 처리 지연이 생겨 code가 만료되는 경우
  • OS가 프로세스를 종료해 verifier 저장이 날아가는 경우

대응:

  • verifier를 메모리 말고 안전한 저장소에 보관(모바일 키체인 등)
  • 콜백 처리 경로를 최대한 짧게

프록시 뒤에서 redirect_uri가 내부 주소로 나가요

  • 외부는 https://app.example.com
  • 내부는 http://service:3000

이 상태에서 서버가 현재 요청 기반으로 redirect_uri를 만들면 불일치가 납니다.

대응:

  • redirect_uri는 설정값으로 고정
  • 프레임워크의 forwarded header 신뢰 설정을 올바르게 적용

결론: invalid_grant는 "3개 값"부터 의심하자

PKCE에서 invalid_grant는 대부분 아래 중 하나로 귀결됩니다.

  • code_verifier가 처음 생성한 값과 다르다(저장/전달/인코딩)
  • redirect_uri가 authorize와 token에서 다르다
  • code가 이미 사용됐거나 만료됐다(중복 실행/지연)

문제 재현이 어렵다면, 우선 토큰 교환 요청을 "한 번만" 보내도록 만들고, redirect_uri를 상수로 고정하고, PKCE 인코딩을 위 예제처럼 Base64URL로 통일하세요. 이 세 가지만 정리해도 invalid_grant의 대부분은 사라집니다.