Published on

OAuth2 PKCE 400 invalid_grant 원인·해결 가이드

Authors

서드파티 로그인이나 사내 SSO를 붙이다 보면, Authorization Code + PKCE 흐름에서 토큰 엔드포인트가 400 과 함께 invalid_grant 를 반환하는 순간이 자주 옵니다. 문제는 이 에러가 원인 범위가 넓고(코드 재사용, 리다이렉트 불일치, PKCE 검증 실패, 만료 등), IdP마다 메시지가 뭉뚱그려져 있어 로그만 보고는 감이 잘 안 온다는 점입니다.

이 글은 PKCE 기반 OAuth2에서 invalid_grant원인별로 분류하고, 증상-진단 포인트-해결책을 연결해 빠르게 고치도록 돕는 실전 가이드입니다.

invalid_grant가 의미하는 것

OAuth2 스펙에서 invalid_grant 는 “제공된 grant(여기서는 authorization code)가 유효하지 않다”는 포괄적 의미입니다. Authorization Code + PKCE에서는 보통 아래 중 하나입니다.

  • authorization code 자체가 유효하지 않음(만료, 이미 사용됨, 잘못된 코드)
  • code를 발급받을 때의 조건과 토큰 교환 시 조건이 불일치(redirect URI, client, PKCE verifier 등)
  • PKCE 검증 실패(code_verifier 불일치 또는 누락)

즉, 토큰 요청은 정상적으로 도달했지만, 서버가 “이 코드로는 토큰을 줄 수 없다”고 판단한 상황입니다.

가장 흔한 원인 TOP 8

아래는 실제 운영에서 빈도가 높은 순서대로 정리한 원인들입니다.

1) redirect_uri 불일치(가장 흔함)

증상

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

원인

  • 토큰 요청의 redirect_uri인가 요청의 redirect_uri 와 1바이트라도 다르면 실패합니다.
  • 특히 아래가 자주 틀립니다.
    • httphttps 혼용
    • 트레일링 슬래시(/) 유무
    • 쿼리스트링 포함 여부
    • 프록시 뒤에서 외부 도메인과 내부 도메인이 다름

해결

  • 인가 요청과 토큰 요청에 완전히 동일한 redirect_uri 를 사용하세요.
  • 프록시 환경에서는 외부 URL을 기준으로 구성하고, 앱에서는 X-Forwarded-Proto / X-Forwarded-Host 를 신뢰하도록 설정해야 합니다.

토큰 요청 예시(curl):

curl -X POST "https://idp.example.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=my-client" \
  -d "code=AUTH_CODE" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "code_verifier=VERIFIER_STRING"

2) code_verifier 누락 또는 값 변경(PKCE 검증 실패)

증상

  • 토큰 요청에 code_verifier 를 넣지 않았거나, 넣었는데도 invalid_grant

원인

  • 인가 요청에서 만든 code_challenge 와 토큰 요청의 code_verifier 가 매칭되지 않음
  • code_verifier 를 저장해두지 못하고 재생성함
  • 모바일/SPA에서 페이지 리로드로 메모리 상태가 날아감
  • 서버가 여러 대인데 세션 스티키가 없어서 verifier를 못 찾음

해결

  • code_verifier 는 “인가 요청 시작 시 생성”해서 “토큰 교환 시점까지” 안전하게 보관해야 합니다.
    • SPA: sessionStorage 사용 권장(탭 단위), 보안 요구에 따라 메모리+재시도 전략
    • 서버: 사용자 세션 저장소(예: Redis)로 보관
  • 토큰 교환 전, 실제로 어떤 verifier가 전송되는지 네트워크 캡처로 확인하세요.

Node.js(개념 예시) PKCE 생성:

import crypto from "crypto";

function base64url(buf) {
  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" };
}

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

증상

  • 특정 IdP에서만 invalid_grant

원인

  • 인가 요청에 code_challenge_method=S256 를 보냈는데, 실제 challenge를 SHA-256으로 만들지 않음
  • 혹은 plain 으로 보냈는데 서버는 S256 만 허용
  • 라이브러리가 자동으로 plain 을 선택하거나, 설정이 누락됨

해결

  • 가능하면 무조건 S256 사용
  • 인가 요청 URL에 code_challenge_method=S256 가 포함되는지, challenge가 올바른 base64url인지 확인

4) authorization code 만료(짧은 유효시간)

증상

  • 로컬에서는 되는데 운영에서 간헐적으로 실패
  • 사용자가 로그인 후 잠시 멈췄다가 돌아오면 실패

원인

  • code의 TTL이 매우 짧은 IdP가 많습니다(수십 초~수 분)
  • 네트워크 지연, 앱 처리 지연, 콜드스타트, 리다이렉트 체인 증가로 토큰 교환이 늦어짐

해결

  • 콜백을 받으면 즉시 토큰 교환을 수행
  • 콜드스타트/지연이 의심되면 서버 타임아웃/리트라이 정책을 점검(무의미한 리트라이는 코드 만료를 악화)
  • 분산 환경에서 데드라인/리트라이를 잘못 잡아 폭주가 나면 인증도 같이 불안정해집니다. 관련해서는 gRPC MSA에서 데드라인·리트라이 폭주 막는 법도 참고할 만합니다.

5) authorization code 재사용(중복 토큰 교환)

증상

  • 첫 시도는 성공, 동일 코드로 두 번째 요청부터 invalid_grant
  • 혹은 사용자 입장에서는 한 번인데 서버 로그에는 토큰 요청이 2번 찍힘

원인

  • code는 1회성입니다.
  • 다음 케이스가 흔합니다.
    • 콜백 처리에서 302 리다이렉트를 잘못 구성해 콜백이 두 번 호출
    • 브라우저가 뒤로가기/새로고침으로 콜백 URL을 재호출
    • 프론트와 백엔드가 각각 토큰 교환을 시도(이중 교환)
    • 네트워크 타임아웃 후 클라이언트가 재시도했지만, 서버에서는 이미 성공 처리

해결

  • 콜백 엔드포인트는 멱등성에 가깝게 설계(예: state 를 키로 1회 처리)
  • 프론트가 토큰 교환을 하면 백엔드는 하지 않거나, 반대로 역할을 명확히 분리
  • 타임아웃/재시도 설계 시 “서버에서 성공했는데 클라이언트가 실패로 오인”하는 케이스를 줄이기

6) client_id / 클라이언트 인증 방식 불일치

증상

  • 어떤 환경에서는 되는데 특정 환경에서만 invalid_grant

원인

  • 퍼블릭 클라이언트(모바일/SPA)인데 client_secret 를 잘못 사용하거나
  • 컨피덴셜 클라이언트(서버)인데 Basic Auth 또는 폼 파라미터 방식이 IdP 기대와 다름

해결

  • IdP 문서에 맞춰 토큰 엔드포인트 인증 방식을 통일
  • 퍼블릭 클라이언트는 보통 client_secret 없이 PKCE로 보호

7) state 처리 오류로 잘못된 코드에 교환 시도

증상

  • 사용자 A의 브라우저에서 사용자 B의 로그인 흐름이 섞이는 듯한 이상 현상
  • 간헐적 invalid_grant

원인

  • state 를 세션에 저장하지 않거나, 검증하지 않거나, 동시 로그인 시도를 구분하지 못함
  • 결과적으로 “내가 받은 code”가 아니라 “다른 흐름의 code”로 토큰 교환을 시도

해결

  • state 는 CSRF 방어이면서 동시성 제어 키입니다.
  • state 를 요청 단위로 생성하고, 콜백에서 동일성 검증 후에만 토큰 교환

8) 서버 시간 오차(Clock skew)로 인한 간접 문제

증상

  • 특정 서버에서만 유독 실패
  • 토큰 발급 후 검증에서도 401이 섞여 나옴

원인

  • code 만료 판단은 IdP가 하지만, 애플리케이션이 세션/캐시 TTL을 잘못 계산하거나, 후속 JWT 검증에서 시간 오차로 연쇄 오류가 발생할 수 있습니다.

해결

재현 가능한 진단 체크리스트

문제를 빠르게 좁히려면 “인가 요청”과 “토큰 요청”을 한 세트로 묶어서 비교해야 합니다.

1) 인가 요청에서 반드시 기록할 것

  • redirect_uri
  • client_id
  • state
  • code_challenge
  • code_challenge_method
  • 발급된 code
  • 발급 시각

2) 토큰 요청에서 반드시 기록할 것

  • redirect_uri (전송값 그대로)
  • client_id
  • code
  • code_verifier 길이(값 전체를 로그로 남기기 어렵다면 길이와 해시만)
  • 요청 시각
  • 응답 바디의 errorerror_description 원문

보안상 code_verifier 를 평문으로 남기기 어렵다면, 아래처럼 해시를 남겨 동일성만 확인할 수 있습니다.

import crypto from "crypto";

function sha256Hex(s) {
  return crypto.createHash("sha256").update(s, "utf8").digest("hex");
}

// log: verifierLen, verifierHash, state
console.log({
  verifierLen: verifier.length,
  verifierHash: sha256Hex(verifier),
  state
});

프록시/로드밸런서 환경에서의 함정

운영에서만 invalid_grant 가 나는 경우, 상당수는 프록시 뒤에서 redirect_uri 가 엇갈립니다.

  • 외부는 https://app.example.com/callback
  • 내부 애플리케이션이 인식하는 base URL은 http://10.0.0.12:8080/callback

이 상태에서 인가 요청은 외부 URL로 나가고, 토큰 요청은 내부 URL로 구성되면 redirect_uri 불일치로 즉시 실패합니다.

대응

  • 애플리케이션이 외부 스킴/호스트를 인식하도록 설정
  • Spring 계열이면 ForwardedHeaderFilter 또는 프레임워크 권장 설정을 적용

이런 “인프라 레이어에서만 재현되는 인증 실패”는 클라우드 권한 위임이나 웹 아이덴티티 흐름에서도 자주 나타납니다. 성격은 다르지만, 네트워크/프록시/타임아웃 관점의 트러블슈팅 방식은 EKS IRSA에서 AssumeRoleWithWebIdentity 0s 타임아웃 해결도 참고가 됩니다.

PKCE 파라미터 규격에서 자주 틀리는 디테일

  • code_verifier 길이: 보통 43~128 문자 범위(스펙 권장). 너무 짧거나 너무 길면 IdP가 거부할 수 있음
  • base64가 아니라 base64url 이어야 함(패딩 = 제거, +/ 치환)
  • S256 일 때 challenge는 SHA-256(verifier) 를 base64url로 인코딩한 값

테스트할 때는 verifier와 challenge를 고정해두고, 서버가 기대하는 값과 비교하면 빠릅니다.

실전 해결 전략: 원인별 빠른 처방전

아래 순서로 보면 대개 10분 안에 범위를 좁힐 수 있습니다.

  1. redirect_uri 를 인가 요청/토큰 요청에서 그대로 비교
    • 다르면 1순위 원인 확정
  2. 토큰 요청이 중복으로 나가는지 확인
    • 콜백 엔드포인트 로그에서 동일 code 로 2회 요청 흔적이 있으면 재사용 문제
  3. code_verifier 보관 방식 점검
    • 리로드/멀티탭/멀티노드에서 verifier가 바뀌는지
  4. code_challenge_method 와 실제 계산 로직 확인
  5. 코드 만료 가능성 확인
    • 발급 시각과 토큰 요청 시각 차이
  6. 클라이언트 타입(퍼블릭/컨피덴셜)과 인증 방식 재점검

마무리

400 invalid_grant 는 “토큰 엔드포인트가 싫어하는 뭔가가 있다”는 신호일 뿐, 실제 원인은 대부분 불일치(redirect URI, PKCE verifier, client 설정) 또는 1회성 코드 특성(재사용/만료)에서 나옵니다.

운영에서 재현이 어렵다면, 인가 요청과 토큰 요청의 핵심 파라미터를 한 트랜잭션으로 묶어 관측 가능하게 만들고(특히 redirect_uri, state, verifier 해시), 프록시 환경에서 외부 URL 기준으로 정렬하는 것만으로도 해결되는 경우가 많습니다.