Published on

OAuth2 PKCE 400 invalid_grant 원인과 해결법

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code with PKCE 플로우에서 토큰 엔드포인트가 400 과 함께 invalid_grant 를 반환하는 상황을 자주 만납니다. 문제는 invalid_grant 가 너무 포괄적인 에러라서, 로그를 봐도 원인 파악이 느리다는 점입니다.

이 글은 PKCE 기준으로 invalid_grant 를 유발하는 원인을 토큰 교환 단계에 필요한 값들의 불일치라는 관점에서 정리하고, 가장 흔한 실수부터 운영 환경에서만 터지는 함정까지 빠르게 좁혀갈 수 있게 구성했습니다.

관련해서 리다이렉트 설정 문제는 invalid_grant 와 함께 동반되는 경우가 많으니, 아래 글도 함께 보면 진단 속도가 빨라집니다.

1) 먼저 확인할 것: 에러가 나는 “지점”

PKCE 플로우에서 invalid_grant 는 보통 토큰 교환 요청에서 발생합니다.

  1. /authorize 에서 code 발급
  2. /token 에서 codeaccess_token 으로 교환

즉, 브라우저에서 로그인은 끝났는데 백엔드(또는 SPA)가 /token 호출에서 실패하는 형태가 전형적입니다. 이때 토큰 요청의 핵심 입력은 다음 4가지입니다.

  • code : 인가 코드
  • redirect_uri : 인가 요청 때 사용한 리다이렉트 URI와 “완전 동일”해야 하는 값
  • code_verifier : 최초 생성한 PKCE 검증 문자열
  • client_id (및 클라이언트 인증 방식) : Public/Confidential 설정에 따라 요구 조건이 달라짐

이 중 하나라도 서버가 기대하는 값과 다르면 invalid_grant 로 뭉뚱그려 떨어지는 경우가 많습니다.

2) 원인 1: code_verifier 불일치 (가장 흔함)

증상

  • /authorize 는 성공
  • /token 에서 invalid_grant
  • IdP 로그에 PKCE verification failed 류 메시지

대표 실수

  • code_verifier 를 요청마다 새로 생성하고, 토큰 교환 시점에 다른 값이 들어감
  • 멀티탭/뒤로가기/재시도 중에 verifier 저장소가 덮어써짐
  • 모바일에서 앱 재시작으로 메모리 저장 값이 사라짐

해결

  • state 를 키로 해서 code_verifier 를 안정적으로 저장하고, 콜백에서 state 로 꺼내 쓴다
  • SPA라면 sessionStorage 가 일반적(탭 단위 격리)
  • 서버가 중간에서 처리한다면 Redis 같은 외부 저장소로 state 기반 저장

Node.js 예제: PKCE 생성 및 검증 값 보관

import crypto from 'crypto';

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

export function createPkcePair() {
  const verifier = base64url(crypto.randomBytes(32));
  const challenge = base64url(
    crypto.createHash('sha256').update(verifier).digest()
  );
  return { verifier, challenge, method: 'S256' as const };
}

콜백 처리에서 state 를 이용해 verifier를 찾는 구조를 강제하면, “다른 verifier로 교환”하는 실수를 크게 줄일 수 있습니다.

3) 원인 2: redirect_uri 가 인가 요청과 1바이트라도 다름

PKCE든 아니든 Authorization Code 교환에서 redirect_uri 는 종종 필수이며, 많은 IdP가 인가 요청 때의 redirect_uri 와 토큰 요청의 redirect_uri 가 완전히 동일하길 요구합니다.

흔한 불일치 패턴

  • 쿼리스트링 유무 차이: ...?a=b 를 붙였다/안 붙였다
  • 트레일링 슬래시 차이: /callback vs /callback/
  • 스킴 차이: http vs https
  • 포트 차이: :3000 포함 여부
  • URL 인코딩 차이(특히 : / 인코딩을 잘못 처리)

해결

  • 인가 요청을 만들 때 사용한 redirect_uri 를 그대로 저장해 토큰 요청에 재사용
  • 환경별(로컬/스테이징/프로덕션) URI를 명시적으로 분리

이 이슈는 워낙 빈도가 높아 별도 체크리스트를 참고하는 게 빠릅니다.

4) 원인 3: code 재사용 또는 만료

Authorization Code는 일반적으로

  • 짧은 TTL
  • 1회성

입니다. 따라서 다음 상황에서 invalid_grant 가 발생합니다.

발생 시나리오

  • 콜백 URL을 사용자가 새로고침해서 같은 code 로 토큰 교환을 두 번 시도
  • 서버/클라이언트가 재시도 로직을 잘못 넣어 중복 교환
  • 네트워크 지연으로 TTL이 짧은 IdP에서 만료

해결

  • 콜백 처리 시 code 를 “1회 처리”로 만들기
    • 서버라면 code 를 키로 멱등성 테이블/캐시에 기록
    • 이미 처리한 code 면 토큰 교환을 스킵하고 기존 세션으로 유도
  • 프론트 라우터에서 콜백 처리 후 즉시 history.replaceState 로 URL에서 code 제거

브라우저 예제: 콜백 처리 후 URL 정리

// 콜백 라우트 진입 직후
const url = new URL(window.location.href);
if (url.searchParams.get('code')) {
  url.searchParams.delete('code');
  url.searchParams.delete('state');
  window.history.replaceState({}, document.title, url.toString());
}

5) 원인 4: PKCE 파라미터 자체가 규격/정책과 불일치

5-1) code_challenge_method 미지원

IdP에 따라 plain 을 막고 S256 만 허용하거나, 반대로 레거시로 plain 만 허용하는 경우도 있습니다.

  • 인가 요청에 code_challenge_method=S256 를 넣었는데 IdP가 실제로는 plain 만 처리
  • 또는 정책상 S256 강제인데 plain 으로 보내버림

해결은 단순합니다.

  • 가능하면 S256 사용
  • IdP 문서에서 허용 메서드 확인

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

RFC 7636에서 code_verifier 는 대략적으로

  • 길이: 43~128
  • 문자: URL safe

요구를 따릅니다. 랜덤 생성이 아니라 임의 문자열을 쓰거나, base64를 그대로 써서 + / = 가 섞이면 실패할 수 있습니다.

위의 예제처럼 base64url로 정규화하면 대부분 해결됩니다.

6) 원인 5: Public/Confidential 클라이언트 설정 충돌

PKCE는 원래 “클라이언트 시크릿을 안전하게 보관하기 어려운 앱”을 위해 널리 쓰입니다. 그런데 IdP 설정에서 클라이언트 타입/인증 방법이 꼬이면 invalid_grant 로 떨어질 수 있습니다.

자주 보는 꼬임

  • SPA인데 클라이언트를 Confidential로 만들어 client_secret 을 요구
  • 모바일 앱인데 토큰 요청에 client_secret 을 붙여 보내 정책에 걸림
  • token_endpoint_auth_methodclient_secret_basic 인데 요청은 body로 보내는 등 방식 불일치

해결 가이드

  • SPA/모바일: Public 클라이언트 + PKCE
  • 서버 웹앱: Confidential 클라이언트 + (선택적으로) PKCE
  • IdP 콘솔에서 token_endpoint_auth_method 와 실제 구현을 일치

토큰 요청 예제: Public 클라이언트(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=my-public-client" \
  --data-urlencode "code=AUTH_CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=VERIFIER" 

토큰 요청 예제: Confidential 클라이언트(기본 인증)

curl -sS -X POST "https://idp.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "my-client-id:my-client-secret" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=AUTH_CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=VERIFIER" 

주의: 일부 IdP는 Confidential이라도 PKCE를 함께 요구하거나 권장합니다. 반대로 어떤 IdP는 Confidential에서 PKCE 파라미터를 허용하지만 의미 없을 수 있습니다. 결국 “IdP 설정과 구현이 일치”가 핵심입니다.

7) 원인 6: state 검증 실패가 invalid_grant 로 보이는 경우

표준적으로 state 는 CSRF 방어용이고, 실패 시점은 보통 콜백 처리 단계입니다. 하지만 구현에 따라 state 가 꼬이면서 verifier 매핑이 깨져 결과적으로 /token 에서 invalid_grant 로 보이기도 합니다.

해결

  • state 는 충분히 랜덤하게 생성
  • state 별로 code_verifier 를 저장
  • 콜백에서 state 가 없거나 매칭 실패면 토큰 교환 자체를 시도하지 말고 즉시 로그인 플로우를 재시작

8) 원인 7: 시간 동기화 문제(운영에서만 발생)

인가 코드 자체 TTL이 짧을 때, 서버 시간이 크게 틀어져 있거나, 프록시/게이트웨이에서 지연이 커지면 간헐적으로 만료로 처리될 수 있습니다.

체크

  • 서버 노드들의 NTP 동기화
  • 토큰 엔드포인트까지의 네트워크 지연(특히 사설망 경유)

인프라 레벨에서 네트워크가 꼬여 인증 요청이 지연될 때가 있는데, 이런 경우 다른 장애와 비슷한 접근(네트워크 경로, 보안그룹, DNS)을 점검하는 습관이 도움이 됩니다.

9) 빠른 진단을 위한 체크리스트(실전)

아래 순서로 보면 대부분 10분 내로 좁혀집니다.

9-1) 토큰 요청 payload를 “그대로” 로깅

민감정보는 마스킹하되, 다음 항목은 최소한 길이/존재 여부를 남기세요.

  • client_id
  • redirect_uri
  • code 존재 여부 및 길이
  • code_verifier 존재 여부 및 길이
  • code_challenge_method

예: code_verifier_len=64 처럼 길이만 남겨도 큰 도움이 됩니다.

9-2) 인가 요청과 토큰 요청의 redirect_uri 를 문자열 비교

  • 공백 포함 여부
  • 대소문자
  • 트레일링 슬래시
  • 포트

9-3) code 재사용 여부 확인

  • 콜백 라우트가 두 번 호출되는지
  • 프론트에서 새로고침/뒤로가기 시 어떤 일이 일어나는지

9-4) PKCE 생성 로직 검증

  • base64url 인코딩인지
  • S256 해시가 맞는지
  • verifier 길이가 정책 범위인지

10) 마무리: invalid_grant 는 “값 불일치”로 생각하자

PKCE에서 400 invalid_grant 는 대개 다음 중 하나로 귀결됩니다.

  • code_verifier 가 원래 것과 다르다
  • redirect_uri 가 인가 요청 때와 다르다
  • code 가 만료되었거나 이미 사용됐다
  • 클라이언트 인증 방식/정책이 설정과 다르다

가장 좋은 해결책은, 단발성 디버깅이 아니라 구조적으로 불일치가 발생하기 어려운 구현으로 바꾸는 것입니다.

  • state 기준으로 verifier를 저장하고, 멀티탭/재시도를 고려
  • 콜백 처리 후 URL 정리로 중복 교환 방지
  • 인가 요청에서 사용한 redirect_uri 를 저장해 토큰 요청에 재사용

이 3가지만 지켜도 PKCE의 invalid_grant 는 체감상 대부분 사라집니다.