Skip to content

PKCE (RFC 7636) in detail

logi accepts only S256. plain is rejected (it is not boundary-safe).

Why PKCE is required

An OAuth 2.0 authorization code travels back to the RP app through the network and the browser. If the code is intercepted along the way:

  • Without PKCE: an attacker can exchange the stolen code + a stolen client_secret for tokens
  • With PKCE: even if the code is stolen, the attacker lacks the code_verifier (the original random value, never exposed in the authorization redirect), so they get invalid_grant

A mobile app/SPA cannot store a client_secret safely, which makes PKCE all the more essential.

Generation formula

verifier   = 43–128 chars of URL-safe random (unreserved characters only)
challenge  = BASE64URL-no-pad(SHA256(verifier))

RFC 7636 Appendix B test vector

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

logi uses this vector in its RSpec suite too (spec/lib/oauth/rfc7636_pkce_vectors_spec.rb).

Implementation by language

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)

Where to store it

  • Web: sessionStorage (per tab) — disappears automatically when the tab closes
  • iOS: memory only (the flow completes within a single actor/screen) — no Keychain storage needed
  • Server-side SSR: a signed cookie or a session store

Do not log the verifier

It is a value needed only up until the code exchange. Confirm masking in your API client logger / error reporter.

Verification failure example

bash
# Deliberately wrong 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

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

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