Skip to content

PKCE (RFC 7636) 상세

logi는 S256만 수락합니다. plain은 거부됩니다 (boundary-safe 아님).

왜 PKCE가 필수인가

OAuth 2.0 Authorization Code는 네트워크/브라우저를 거쳐 제휴사 앱으로 돌아옵니다. 중간에서 code가 탈취되면:

  • PKCE 없이: 공격자가 훔친 code + 훔친 client_secret으로 토큰 교환 가능
  • PKCE 있음: 탈취해도 code_verifier(원본 난수, 서버에 전달된 적 없음)가 없어 invalid_grant

모바일 앱/SPA는 client_secret을 안전하게 보관할 수 없으므로 PKCE가 더더욱 필수입니다.

생성 공식

verifier   = 43 ~ 128자 URL-safe 랜덤 (unreserved 문자만)
challenge  = BASE64URL-no-pad(SHA256(verifier))

RFC 7636 Appendix B 검증 벡터

verifier   = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge  = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

이 벡터는 logi RSpec에서도 사용합니다 (spec/lib/oauth/rfc7636_pkce_vectors_spec.rb).

언어별 구현

ts
async function generatePKCE() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  const challenge = base64url(new Uint8Array(hash));
  return { verifier, challenge };
}

function base64url(bytes: Uint8Array): string {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
swift
import CryptoKit

struct PKCE {
    let verifier: String
    let challenge: String

    static func generate() -> PKCE {
        let random = (0..<32).map { _ in UInt8.random(in: 0...255) }
        let verifier = Data(random).base64URL
        let hash = SHA256.hash(data: Data(verifier.utf8))
        let challenge = Data(hash).base64URL
        return PKCE(verifier: verifier, challenge: challenge)
    }
}

extension Data {
    var base64URL: String {
        base64EncodedString()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
    }
}
ruby
require "securerandom"
require "digest"
require "base64"

verifier  = SecureRandom.urlsafe_base64(32).delete("=")
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
python
import secrets, hashlib, base64

verifier = secrets.token_urlsafe(32).rstrip("=")
challenge = base64.urlsafe_b64encode(
    hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()
kotlin
import java.security.MessageDigest
import android.util.Base64

val verifier = (1..32).map { ('A'..'Z') + ('a'..'z') + ('0'..'9') + '-' + '_' }
    .flatten().shuffled().take(43).joinToString("")
val sha = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray())
val challenge = Base64.encodeToString(sha, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)

저장 위치

  • Web: sessionStorage (tab 단위) — 탭 닫으면 자동 소멸
  • iOS: 메모리만 (플로우가 한 액터/화면 내에서 끝남) — Keychain 저장 불필요
  • 서버 사이드 render SSR: 서명된 쿠키 또는 세션 store

verifier를 로그에 남기지 마세요

code 교환 직전까지만 필요한 값입니다. API 클라이언트 로거/에러 리포터에서 masking 확인하세요.

검증 실패 예시

bash
# 의도적으로 잘못된 verifier
curl -X POST $LOGI/oauth/token \
  -d grant_type=authorization_code \
  -d code=$CODE \
  -d redirect_uri=$REDIRECT \
  -d code_verifier="wrong" \
  -d client_id=$ID -d client_secret=$SECRET

# 응답: 400
# {"error":"invalid_grant","error_description":"PKCE verifier mismatch"}

MIT License · Identity가 제품의 신뢰를 만듭니다.