PKCE (RFC 7636) in detail
Source:
en/oauth/pkce.md· Live: https://docs.1pass.dev/en/oauth/pkce LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L21-L29: ## Why PKCE is required
- L30-L36: ## Generation formula
- L37-L45: ## RFC 7636 Appendix B test vector
- L46-L117: ## Implementation by language
- L118-L126: ## Where to store it
- L127-L141: ## Verification failure example
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
Code examples by language/framework:
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(/=+$/, "");
}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: "")
}
}require "securerandom"
require "digest"
require "base64"
verifier = SecureRandom.urlsafe_base64(32).delete("=")
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)import secrets, hashlib, base64
verifier = secrets.token_urlsafe(32).rstrip("=")
challenge = base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode()).digest()
).rstrip(b"=").decode()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
🚨 Danger: 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
# 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"}