Published on

OAuth2 PKCE invalid_grant 10분 해결 체크리스트

Authors

서버에서 토큰 교환을 붙이는 순간 invalid_grant가 튀면, 체감상 “다 맞는 것 같은데 안 되는” 상태에 빠지기 쉽습니다. 특히 OAuth2 Authorization Code + PKCE 조합에서는 작은 불일치(문자 하나, 인코딩 한 번, 요청 한 번 중복)가 곧바로 invalid_grant로 뭉뚱그려져 돌아옵니다.

이 글은 10분 안에 원인을 좁히는 순서로 정리한 체크리스트입니다. (정답을 외우기보다, 로그와 요청 페이로드를 기준으로 빠르게 배제하는 방식)

참고: 글 내에서 부등호 문자는 MDX 빌드 에러를 피하기 위해 < > 또는 인라인 코드로만 표기합니다.

0) 먼저: invalid_grant가 의미하는 범위

OAuth2 스펙에서 invalid_grant는 대략 아래 범주를 포함합니다.

  • authorization_code만료되었거나 이미 사용됨
  • redirect_uri 불일치
  • 클라이언트 인증(또는 client_id) 불일치
  • (PKCE) code_verifier가 잘못되었거나, code_challenge와 매칭 실패
  • IdP가 내부적으로 “해당 grant를 인정할 수 없음”으로 판단한 모든 케이스

즉, 원인별로 에러 코드가 세분화되어 있지 않은 경우가 많아, “요청/응답을 재현 가능한 형태로 고정”하고 하나씩 제거해야 합니다.

1) 10분 해결 플로우(우선순위 순)

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

1-1. authorization_code 재사용 여부(가장 흔함)

증상: 첫 시도는 성공했는데, 재시도/리트라이/새로고침에서 실패. 혹은 프론트에서 토큰 교환 요청이 2번 나감.

  • authorization_code1회성입니다. 토큰 엔드포인트에 한 번 성공적으로 교환되면 끝입니다.
  • 실패했다면 “정말 실패했는지”도 확인해야 합니다. 네트워크 타임아웃으로 클라이언트는 실패로 보지만, 서버는 이미 교환 성공했을 수 있습니다.

즉시 점검

  • 브라우저 네트워크 탭에서 /token 요청이 2번 이상 찍히는지 확인
  • 서버 로그에서 동일한 code로 token 교환이 반복되는지 확인

예방 패턴(서버에서 단발 처리)

  • code를 키로 1회 처리 락(짧은 TTL) 걸기
  • 프론트에서 token 교환을 트리거하는 useEffect 중복 실행 방지

1-2. redirect_uri 완전 일치(문자 단위)

증상: 로컬/스테이징/프로덕션 환경에서만 실패. 혹은 www 유무, 슬래시 유무로만 바뀌었는데 실패.

대부분의 IdP는 redirect_uri문자열 완전 일치로 비교합니다.

  • https://app.example.com/callback
  • https://app.example.com/callback/

위 둘은 다르게 취급될 수 있습니다.

즉시 점검

  • 인가 요청의 redirect_uri와 토큰 요청의 redirect_uri완전히 동일한지 비교
  • URL 인코딩이 중복 적용되어 redirect_uri가 달라지지 않았는지 확인

1-3. PKCE code_verifier 저장/전송 깨짐

증상: 인가 화면까지는 정상인데 토큰 교환에서만 실패. 특히 모바일 웹/사파리/인앱 브라우저에서 간헐적.

PKCE는 다음이 전제입니다.

  • 인가 요청 시 만든 code_verifier그대로 보관
  • 토큰 요청 시 동일한 code_verifier를 전송

여기서 흔한 사고:

  • code_verifier를 localStorage에 저장했는데, 리다이렉트 과정에서 스토리지 접근이 막히거나 초기화
  • 서버/클라이언트가 서로 다른 verifier를 생성
  • verifier 문자열에 + / = 같은 문자가 섞였는데, 전송 과정에서 폼 인코딩으로 변형

즉시 점검

  • 토큰 요청 직전에 code_verifier 길이와 일부 prefix를 로그로 남겨 비교(전체 로그는 노출 위험)
  • verifier가 RFC 7636 허용 문자 집합을 만족하는지 확인

권장: code_verifierURL-safe 문자로 생성(예: base64url)하고, 전송은 application/x-www-form-urlencoded 규칙에 맞게 정확히 인코딩합니다.

1-4. code_challenge_method 불일치 또는 S256 계산 오류

증상: 어떤 IdP에서는 plain은 되는데 S256에서만 실패하거나, 반대로 S256만 허용.

S256 계산은 다음입니다.

  • code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
  • base64url 변환 시 +-, /_, 패딩 = 제거

즉시 점검

  • 인가 요청에서 code_challenge_method=S256를 보냈는지
  • 토큰 요청에서 보낸 code_verifier가 인가 요청 때의 challenge와 매칭되는지(로컬에서 재계산)

2) 재현 가능한 “정답 요청” 만들기: cURL로 고정

브라우저/SDK가 끼면 리다이렉트, 인코딩, 재시도 로직 때문에 원인이 흐려집니다. 인가 코드(code)를 한 번 확보한 뒤, 토큰 교환을 cURL로 고정하면 빠르게 좁혀집니다.

아래는 일반적인 토큰 교환 예시입니다(엔드포인트/파라미터는 IdP마다 다름).

curl -sS -X POST "https://idp.example.com/oauth2/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=AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=YOUR_CODE_VERIFIER"

포인트

  • --data-urlencode를 사용해 폼 인코딩 변형을 최소화
  • redirect_uri는 인가 요청에 사용한 값과 완전 일치
  • code_verifier는 원본 그대로(추가 인코딩/디코딩 금지)

3) 서버 구현에서 자주 터지는 디테일

3-1. Next.js/Node에서 PKCE 생성 시 base64url 처리

Node crypto로 verifier/challenge를 만들 때, base64url 변환을 정확히 해야 합니다.

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' }
}

Edge 런타임을 쓰면 crypto 사용 방식이 달라져 런타임 오류가 나거나(혹은 폴리필로 바뀌며 결과가 달라질 수) PKCE 계산이 어긋날 수 있습니다. Edge 환경에서 crypto 이슈를 겪는다면 아래 글도 같이 점검하세요.

3-2. application/x-www-form-urlencoded를 JSON으로 보내는 실수

토큰 엔드포인트는 보통 JSON이 아니라 폼 인코딩을 요구합니다.

// Node 18+ fetch 예시
const body = new URLSearchParams({
  grant_type: 'authorization_code',
  client_id: process.env.CLIENT_ID,
  code,
  redirect_uri: redirectUri,
  code_verifier: verifier,
})

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

JSON으로 보내면 IdP가 파라미터를 못 읽고 invalid_grant 또는 유사 에러로 떨어뜨리는 경우가 있습니다.

3-3. client_secret을 붙이면 오히려 실패하는 케이스

PKCE는 주로 Public Client(예: SPA, 모바일)에서 사용합니다. IdP 설정에 따라:

  • Public client로 등록했는데 token 요청에 client_secret을 보내면 실패
  • Confidential client로 등록했는데 client_secret이 없으면 실패

즉시 점검

  • IdP 콘솔에서 앱 타입이 Public/Confidential 중 무엇인지
  • token 요청이 그 타입에 맞는 인증 방식을 쓰는지

4) 시간/만료/시계 문제(의외로 자주 보임)

authorization_code는 보통 수십 초~수 분 내 만료입니다. 그런데 다음 상황에서 “10분쯤 디버깅하다가 계속 실패”가 발생합니다.

  • 인가 코드를 받은 뒤, 디버깅하느라 오래 끌어서 만료
  • 서버 시계가 크게 틀어져서 IdP가 만료로 판단
  • 로드밸런서 뒤 다중 인스턴스에서 세션/스토리지 불일치로 verifier를 못 찾음

즉시 점검

  • 코드를 받은 직후 바로 교환했는지(재현 시에는 즉시)
  • 서버 NTP 동기화 상태
  • verifier 저장소가 인스턴스 간 공유되는지(메모리 저장은 멀티 인스턴스에서 깨짐)

5) 브라우저/앱 환경별 함정: 스토리지와 리다이렉트

PKCE에서 verifier를 어디에 저장하는지가 핵심입니다.

  • SPA에서 sessionStorage 사용: 탭/리다이렉트 흐름에서는 대체로 안전하지만, 특정 인앱 브라우저에서 초기화될 수 있음
  • localStorage 사용: 지속성은 좋지만 보안/정책 이슈, 사파리 프라이빗 모드 등 변수 존재
  • 서버 세션(권장): 인가 요청을 시작한 서버가 verifier를 세션에 저장하고, 콜백에서 세션으로 복원

서버 세션 전략 예시(개념 코드)

// 1) /auth/start
// 세션에 verifier 저장 후, challenge로 authorize 리다이렉트
req.session.pkceVerifier = verifier
redirectToAuthorize({ code_challenge: challenge })

// 2) /auth/callback
const verifier = req.session.pkceVerifier
if (!verifier) throw new Error('PKCE verifier missing in session')
exchangeToken({ code, verifier })

멀티 인스턴스라면 세션 스토어를 Redis 같은 공유 저장소로 두어야 합니다.

6) “10분 체크리스트” 한 장 요약

아래를 위에서부터 순서대로 확인하세요.

  1. token 요청이 중복으로 나가 code를 재사용하고 있지 않은가
  2. 인가 요청과 토큰 요청의 redirect_uri완전 일치하는가(슬래시, www, 스킴, 포트)
  3. code_verifier가 리다이렉트 사이에 그대로 보존되는가(세션/스토리지)
  4. S256 계산이 정확한가(base64url, 패딩 제거)
  5. 토큰 요청 Content-Type이 application/x-www-form-urlencoded인가
  6. 클라이언트 타입(Public/Confidential)과 인증 방식(client_secret 유무)이 맞는가
  7. 코드 만료/서버 시간/NTP/멀티 인스턴스 세션 불일치가 없는가

7) 로그를 어떻게 남기면 빠르게 끝나는가

민감정보를 그대로 남기지 않으면서도 디버깅 가능한 최소 로그는 다음 조합이 좋습니다.

  • code의 앞 6자 + 길이
  • code_verifier의 앞 6자 + 길이
  • redirect_uri 전체
  • token 요청이 발생한 타임스탬프(서버)
  • 요청을 트리거한 경로(콜백 핸들러 진입 로그)

예시(개념):

token-exchange start: code=AbC123... len=120 redirect_uri=https://app.example.com/callback verifier=ZxY987... len=43

이 정도만 있어도 “중복 호출”, “verifier 분실”, “redirect 불일치”, “길이 이상”을 빠르게 잡습니다.

8) 마무리: 가장 빨리 고치는 방법은 ‘고정된 입력’

invalid_grant는 대개 서버/클라이언트/IdP 중 한 곳의 “작은 불일치”입니다. 브라우저에서 이것저것 만지기보다,

  • 인가 코드 발급 직후
  • 동일한 redirect_uri
  • 동일한 code_verifier

이 3가지를 고정한 cURL 토큰 교환으로 기준선을 만들면, 그 다음부터는 구현체(Next.js, 모바일, 프록시, 로드밸런서) 문제를 분리해서 볼 수 있습니다.

추가로, Next.js 런타임(특히 Edge)에서 암호 관련 API 차이로 PKCE 구현이 흔들릴 수 있으니, 환경 이슈가 의심되면 아래 글도 함께 확인해보세요.