Published on

OAuth2 PKCE에서 invalid_grant 나는 7가지

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, PKCE까지 제대로 구현했는데도 토큰 교환 단계에서 invalid_grant가 튀어나오는 순간이 있습니다. 문제는 invalid_grant가 너무 포괄적인 에러라서, 실제 원인이 code_verifier 불일치인지, redirect_uri 미스매치인지, 코드 재사용인지 한 번에 감이 안 온다는 점입니다.

이 글은 OAuth2 Authorization Code + PKCE 흐름에서 invalid_grant가 발생하는 대표 원인 7가지를, “어디서 깨지는지” 기준으로 빠르게 좁혀갈 수 있게 정리한 체크리스트입니다.

관련해서 redirect_uri 불일치가 의심된다면 아래 글도 같이 보면 디버깅 시간이 크게 줄어듭니다.


PKCE에서 invalid_grant가 주로 터지는 지점

대부분 다음 요청에서 발생합니다.

  • POST token endpoint (/oauth/token 또는 /token)
  • grant_type=authorization_code
  • codecode_verifier를 함께 제출

서버는 대개 아래 중 하나가 실패하면 invalid_grant로 뭉뚱그려 반환합니다.

  • authorization code 검증 실패(만료, 재사용, 클라이언트 불일치)
  • redirect_uri 검증 실패
  • PKCE 검증 실패(code_verifier로 계산한 challenge가 저장된 값과 다름)

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

증상

  • 어떤 IdP는 아예 invalid_grant로만 떨어짐
  • 어떤 IdP는 invalid_request로 떨어지기도 함

원인

PKCE code_verifier는 RFC 7636 규격상 길이와 문자셋 제한이 있습니다.

  • 길이: 43~128
  • 문자: unreserved 문자 집합(대체로 URL safe)

실전에서 흔한 실수는 다음입니다.

  • 너무 짧거나(예: 32바이트 base64를 잘라 40자 미만)
  • +, /, = 같은 표준 base64 문자가 섞이거나
  • URL 인코딩/디코딩 과정에서 값이 변형

해결

  • base64url 인코딩을 사용하고 패딩 =를 제거
  • verifier를 생성할 때 길이가 43 이상인지 강제

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

import crypto from 'crypto';

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

export function createCodeVerifier() {
  // 32바이트면 base64url로 대략 43자 이상이 됨
  return base64url(crypto.randomBytes(32));
}

2) code_challenge_method 불일치 또는 plain 처리 실수

증상

  • authorization request는 성공
  • token request에서 invalid_grant

원인

클라이언트가 S256로 보냈다고 생각했는데 실제로는 다음 중 하나가 됨.

  • 서버는 S256만 허용하는데 클라이언트가 plain로 보냄
  • code_challenge_method=S256는 보냈지만 code_challenge 계산이 표준과 다름

특히 모바일/프론트에서 해시 계산 후 base64url 변환을 잘못하면 100% invalid_grant로 이어집니다.

해결

  • S256을 사용하고, 해시 결과를 base64url로 인코딩
  • challenge 계산 로직을 테스트 코드로 고정

예시: S256 challenge 계산 (Node.js)

import crypto from 'crypto';

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

export function createCodeChallengeS256(verifier) {
  const hash = crypto.createHash('sha256').update(verifier).digest();
  return base64url(hash);
}

검증 팁: 서버에 저장된 code_challenge와, 클라이언트가 code_verifier로부터 다시 계산한 값이 동일해야 합니다.


3) redirect_uri가 authorization 단계와 token 단계에서 1바이트라도 다름

증상

  • 로그인/동의 화면까지는 정상
  • token 교환에서 invalid_grant

원인

OAuth2 스펙과 다수 IdP 구현은 “authorization request에서 사용한 redirect_uri”와 “token request에서 제출한 redirect_uri”가 정확히 일치해야 한다는 규칙을 강하게 적용합니다.

자주 발생하는 차이:

  • httpshttp 혼용(프록시/로드밸런서 뒤)
  • trailing slash 차이: .../callback vs .../callback/
  • 쿼리스트링 포함 여부
  • URL 인코딩 차이
  • 포트 유무: :443 포함/미포함

해결

  • authorization 요청에 보낸 redirect_uri 값을 그대로 저장해두고, token 요청에 동일 문자열을 재사용
  • 서버 프록시 환경이면 X-Forwarded-Proto 등을 반영해 redirect URL 생성 로직을 고정

redirect_uri 디버깅은 케이스가 많아서 아래 글을 같이 참고하는 것을 권장합니다.


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

증상

  • 어떤 요청은 성공, 어떤 요청은 invalid_grant
  • 특히 SPA에서 더 자주 발생

원인

authorization code는 1회성입니다. 다음 상황에서 “의도치 않은 2회 교환”이 자주 일어납니다.

  • 프론트에서 callback 페이지가 두 번 마운트됨(React Strict Mode 개발환경 등)
  • 네트워크 재시도 로직이 token 요청을 자동 재전송
  • 백엔드와 프론트가 동시에 code를 교환하려고 함

해결

  • code 교환은 단 하나의 컴포넌트/서버 경로에서만 수행
  • 멱등성 키를 두거나, code를 교환한 즉시 “처리 완료” 상태로 마킹
  • 프론트라면 callback 처리 시 1회 실행 가드 추가

예시: 브라우저에서 중복 실행 방지(간단 가드)

const key = 'oauth_code_exchange_done';
if (sessionStorage.getItem(key) === '1') {
  // 이미 처리했으면 더 이상 교환하지 않음
} else {
  sessionStorage.setItem(key, '1');
  // token exchange 수행
}

5) code 만료 또는 서버-클라이언트 시간 불일치

증상

  • 사용자가 로그인 후 잠깐 다른 앱을 보다가 돌아오면 invalid_grant
  • 특정 환경에서만 간헐적으로 발생

원인

authorization code는 보통 유효 시간이 매우 짧습니다(수십 초~수분). 다음이 겹치면 만료로 처리됩니다.

  • 사용자 지연(동의 화면에서 오래 머뭄)
  • 네트워크 지연
  • 서버 시간이 크게 틀어짐(NTP 미설정)

해결

  • code 발급부터 교환까지의 시간을 최대한 단축
  • 서버 NTP 동기화 확인
  • 모바일 딥링크/앱 전환 시 callback이 늦어지는 플로우를 점검

서버 시간 문제는 인증뿐 아니라 TLS에서도 이상 증상을 만들 수 있어, 네트워크 관점 점검이 필요할 때는 아래 글의 접근 방식도 도움이 됩니다.


6) 다른 클라이언트로 발급된 code를 교환(클라이언트 ID 혼선)

증상

  • 로컬에서는 되는데 스테이징/프로덕션에서만 invalid_grant
  • 멀티 테넌트/멀티 앱에서 특히 자주 발생

원인

authorization code는 “발급된 client”에 귀속됩니다.

  • A 앱의 client_id로 authorization을 시작했는데
  • token 교환은 B 앱의 client_id(또는 다른 환경의 client)로 요청

이때 서버는 보통 invalid_grant로 처리합니다.

해결

  • 환경별 client_id/issuer/authorization endpoint/token endpoint 조합을 고정
  • callback 처리 서버에서 “어떤 client로 시작했는지” 컨텍스트를 유지

예시: token 요청에 들어가는 파라미터 점검(cURL)

curl -sS -X POST 'https://issuer.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/callback' \
  --data-urlencode 'code_verifier=YOUR_CODE_VERIFIER'

점검 포인트:

  • client_id가 authorization 요청과 동일한지
  • issuer 도메인이 환경에 맞는지

7) code_verifier 저장/전달 과정에서 값이 바뀜(세션, 쿠키, 인코딩)

증상

  • 특정 브라우저(특히 Safari)나 특정 네트워크에서만 실패
  • 동일 사용자도 성공/실패가 섞임

원인

PKCE는 “authorization 요청 때 만든 code_verifier를 token 요청 때 그대로 제출”해야 합니다. 그런데 실전에서는 verifier를 다음 매체에 저장했다가 꺼내는 과정에서 깨지는 경우가 많습니다.

  • 쿠키에 저장했는데 특수문자 인코딩이 달라짐
  • 서버 세션이 유실됨(로드밸런서 뒤 sticky session 미설정)
  • SPA에서 새로고침으로 메모리 상태가 날아감
  • URL fragment나 query에 실어 나르다 잘못 인코딩

해결

  • verifier는 가능하면 서버 사이드 세션(또는 안전한 스토리지)에 저장
  • 로드밸런서 환경이면 세션 고정 또는 공유 세션 스토어 사용
  • 쿠키/로컬스토리지를 쓴다면 base64url 문자열만 저장하고, 인코딩 변환을 최소화

예시: Express에서 세션에 verifier 저장

import express from 'express';
import session from 'express-session';

const app = express();

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

app.get('/login', (req, res) => {
  const verifier = createCodeVerifier();
  const challenge = createCodeChallengeS256(verifier);

  req.session.pkce = { verifier };

  const authorizeUrl = new URL('https://issuer.example.com/oauth/authorize');
  authorizeUrl.searchParams.set('response_type', 'code');
  authorizeUrl.searchParams.set('client_id', 'YOUR_CLIENT_ID');
  authorizeUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
  authorizeUrl.searchParams.set('code_challenge_method', 'S256');
  authorizeUrl.searchParams.set('code_challenge', challenge);

  res.redirect(authorizeUrl.toString());
});

app.get('/callback', async (req, res) => {
  const code = req.query.code;
  const verifier = req.session.pkce?.verifier;
  if (!verifier) return res.status(400).send('missing pkce verifier');

  // 여기서 token endpoint로 code + verifier 교환
  res.send('ok');
});

빠른 트러블슈팅 체크리스트(로그로 좁히기)

아래 5가지를 한 번에 수집하면 원인 7개 중 대부분이 바로 좁혀집니다.

  1. authorization 요청의 code_challenge, code_challenge_method, redirect_uri, client_id
  2. token 요청의 code, code_verifier, redirect_uri, client_id
  3. code 교환이 중복으로 발생했는지(요청 횟수, 재시도 여부)
  4. code 발급 시각과 교환 시각(만료 여부)
  5. verifier를 저장한 위치(세션/쿠키/스토리지)와 유실 가능성

서버가 에러 상세를 숨기는 경우가 많으니, 가능하면 IdP 쪽 감사 로그(audit log)나 이벤트 로그에서 “왜 invalid_grant로 판정했는지”를 확인하는 것이 가장 빠릅니다.


마무리

PKCE에서 invalid_grant는 “PKCE가 틀렸다”라기보다, 대개는 redirect_uri 문자열 불일치, code 재사용, verifier 변형처럼 구현 주변부에서 발생합니다. 위 7가지를 순서대로 제거해 나가면, 대부분 30분 안에 재현과 수정이 가능합니다.

특히 redirect_uri는 PKCE 여부와 관계없이 가장 빈도가 높은 지뢰밭이므로, 의심되면 아래 글의 체크리스트를 먼저 적용해보세요.