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 getinvalid_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"}