PKCE Demystified — What the RFC Actually Says
    Security

    PKCE Demystified — What the RFC Actually Says

    PKCE is mandatory for public OAuth clients. Here is what the RFC actually specifies — the entropy requirements, the S256 transform, and why "plain" exists but should never be used.

    Reverse PolarityJune 4, 202610 min read

    RFC 7636 is short — twenty pages with generous whitespace and a worked example in the appendix. Most developers treat it as a quick-read confirmation of something they already half-understand: "generate a random string, hash it, send the hash, later prove you have the original." That summary isn't wrong, but it leaves out the parts that actually matter when you're building or auditing an implementation. The subtle stuff is where real vulnerabilities hide.

    What follows goes through the mechanics the RFC specifies — the threat model it was actually designed to address, the exact algorithm behind S256, why plain exists and why you almost certainly shouldn't touch it, the token endpoint verification steps that servers routinely skip, and a few interactions with other OAuth parameters that the RFC describes and most implementations quietly ignore.

    Diagram — PKCE Authorization Flow (RFC 7636)
    Diagram

    1. The attack it was designed to prevent — and what it doesn't cover

    The RFC opens by describing a concrete attack that was observed in the wild on mobile platforms, not a theoretical one invented by a standards committee. The attack targets the authorization code grant on public clients — specifically native apps using custom URI scheme redirects.

    On mobile operating systems, multiple apps can register the same custom URI scheme. There's no enforcement of exclusivity. When the authorization server redirects to com.example.myapp://callback?code=..., the OS has to pick which app handles it. If a malicious app has registered the same scheme, the OS may deliver the authorization code there instead.

    The attacker now has a valid authorization code. On a plain OAuth 2.0 flow without PKCE, they can exchange that code for an access token because the token endpoint has no way to verify that the entity presenting the code is the same one that initiated the authorization request. The client_id is public. For native apps, the client_secret — if there is one — is embedded in the binary and is equally public.

    PKCE closes this gap by binding the token exchange to a secret that was never transmitted in a form the attacker could intercept. The authorization request carries only the hash (the challenge); the original value (the verifier) travels only in the token request, over TLS. An intercepted authorization code is useless without the verifier, and the attacker never saw it.

    What PKCE does not address: MITM attacks against TLS, compromised authorization servers, XSS in browser-based flows, or stolen refresh tokens. The RFC is focused specifically on the interception of the authorization code during the redirect phase.


    2. The entropy requirements for code_verifier are more specific than you think

    Section 4.1 defines code_verifier using ABNF:

    code-verifier = 43*128unreserved
    unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
    

    That gives you a minimum of 43 characters and a maximum of 128, using the unreserved characters from RFC 3986. But the ABNF describes the encoded form, not the generation method. Section 7.1 is where the actual requirement lives:

    The client SHOULD create a code_verifier with a minimum of 256 bits of entropy.

    The recommended approach in the RFC is to generate 32 random octets and base64url-encode them without padding. That gives exactly 43 characters of output, and the underlying 32 bytes provide 256 bits of entropy — assuming you're reading from a cryptographically secure random source.

    import (
        "crypto/rand"
        "encoding/base64"
        "fmt"
    )
    
    func generateVerifier() (string, error) {
        b := make([]byte, 32)
        if _, err := rand.Read(b); err != nil {
            return "", fmt.Errorf("verifier generation failed: %w", err)
        }
        // base64url, no padding — matches RFC 7636 Appendix A
        return base64.RawURLEncoding.EncodeToString(b), nil
    }
    

    The output of base64.RawURLEncoding.EncodeToString on 32 bytes is always 43 characters, always within the allowed character set, and always in the 43–128 range. The 128-character upper bound exists to prevent denial-of-service via absurdly long verifiers.

    Two things developers get wrong here: using math/rand instead of crypto/rand (the entropy requirement is real — a predictable verifier defeats the whole mechanism), and not stripping the base64 padding characters. Standard base64 pads with = signs, which are not in the unreserved set. The RFC's Appendix A is explicit: trailing = characters MUST be omitted.


    3. The S256 transform — what it actually computes

    S256 is defined in Section 4.2:

    code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
    

    Three operations in sequence, each with a specific meaning. ASCII(code_verifier) converts the string to its ASCII byte representation — which for a verifier consisting only of unreserved characters is just the UTF-8 bytes without any BOM or encoding tricks. SHA256(...) applies SHA-2 with a 256-bit digest. BASE64URL-ENCODE(...) encodes the resulting 32 bytes using base64url without padding.

    The resulting challenge is always 43 characters long — the base64url encoding of 32 bytes is always 43 characters without padding.

    import (
        "crypto/sha256"
        "encoding/base64"
    )
    
    func deriveChallenge(verifier string) string {
        h := sha256.Sum256([]byte(verifier))
        return base64.RawURLEncoding.EncodeToString(h[:])
    }
    

    The RFC's Appendix B includes a worked example with concrete octet values, which is worth checking your implementation against. The code verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk should produce the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM. If your implementation reproduces these values, the hashing and encoding are correct.

    The absence of salting is intentional. Section 7.3 explains the reasoning: salting is used in password hashing to prevent precomputed dictionary attacks. That's appropriate for low-entropy passwords. Here the verifier already contains 256 bits of entropy — no dictionary attack is feasible, and adding a public salt would not increase the search space in any meaningful way.


    4. Why plain exists and why the RFC says not to use it

    Two transformation methods are registered: S256 and plain. The plain method is exactly what it sounds like:

    code_challenge = code_verifier
    

    The ABNF for code_challenge is identical to code_verifier — same character set, same length bounds. The challenge is just the verifier transmitted verbatim.

    Section 4.2 states the rule clearly: if the client is capable of using S256, it MUST use S256. The plain method is only permitted when the client genuinely cannot support S256 "for some technical reason" and has confirmed via out-of-band configuration that the server supports it. In practice, this applies to severely constrained environments — something that can't compute a SHA-256 hash — which is vanishingly rare in 2024.

    The security difference is significant. With S256, an attacker who can observe the authorization request sees only the challenge — a one-way hash of the verifier. They can't reverse it to obtain the verifier, so an intercepted code is still worthless. With plain, the challenge is the verifier. An attacker who can observe the authorization request now has everything they need to exchange an intercepted code for a token.

    Section 7.2 spells out a second problem: the RFC forbids downgrading from S256 to plain after a failure. If your client sends code_challenge_method=S256 and the server rejects it, you MUST NOT retry with plain. The RFC's reasoning is that all servers supporting PKCE are required to support S256 — so a rejection of S256 either means the server is broken or a MITM attacker is attempting a downgrade. Silently falling back would surrender the security guarantee.


    5. The token endpoint verification steps are mandatory and ordered

    Section 4.6 describes exactly how the authorization server MUST verify the verifier at the token endpoint. This is where server implementations tend to cut corners.

    The full verification procedure:

    1. Retrieve the code_challenge and code_challenge_method that were associated with the authorization code when it was issued (Section 4.4)
    2. Transform the received code_verifier according to the stored code_challenge_method
    3. Compare the result to the stored code_challenge
    4. If equal, continue processing. If not equal, return invalid_grant
    func verifyPKCE(storedChallenge, storedMethod, receivedVerifier string) error {
        var computed string
        switch storedMethod {
        case "S256":
            h := sha256.Sum256([]byte(receivedVerifier))
            computed = base64.RawURLEncoding.EncodeToString(h[:])
        case "plain":
            computed = receivedVerifier
        default:
            return errors.New("unsupported code_challenge_method")
        }
    
        if computed != storedChallenge {
            return errors.New("invalid_grant: code_verifier mismatch")
        }
        return nil
    }
    

    The failure mode to watch for is implementations that skip this check when a code_verifier is absent. Section 4.6 only covers what to do when a verifier is received. Section 4.4.1 covers the other case: if the server requires PKCE and no code_challenge arrived in the authorization request, it MUST return invalid_request at the authorization endpoint.

    The gap that bites teams: many servers support PKCE but don't require it. A client that omits code_challenge and code_verifier entirely can still complete the flow. That's technically within spec if the server chooses not to mandate PKCE, but it means PKCE is providing no protection — the server is happy to issue tokens without verifying anything. The RFC permits this for backward compatibility, but if you're building an authorization server for public clients, mandating PKCE is the right call.


    6. Server-side storage of the challenge — what the RFC requires

    Section 4.4 has a sentence that implementation guides often omit:

    The server MUST NOT include the code_challenge value in client requests in a form that other entities can extract.

    When an authorization server encodes state into the authorization code itself — a common approach for stateless implementations — the code_challenge must be encrypted, not merely encoded. If the server packs code_challenge into a JWT and signs it with RS256 but doesn't encrypt it, the challenge is readable by anyone who has the authorization code. For S256 that's mostly harmless since the challenge can't be reversed. For plain, it would expose the verifier directly.

    The RFC allows the server to store the challenge either inside the code itself (in encrypted form) or in a server-side store associated with the code. The exact mechanism is out of scope for the RFC — the constraint is only that the challenge must not be extractable from what's visible to the client or to third parties in the redirect chain.


    7. PKCE and state are complementary, not redundant

    A common misconception is that PKCE replaces the need for state. It doesn't.

    state is defined in RFC 6749 and protects against cross-site request forgery — an attacker crafting a malicious authorization response and delivering it to your client. state is a nonce that the client generates, includes in the authorization request, and verifies on the authorization response before proceeding to the token exchange.

    PKCE operates at a different layer. It binds the token exchange to the original authorization request. If an attacker intercepts the authorization code and attempts a token exchange, PKCE stops them — they don't have the verifier.

    What PKCE does not stop: an attacker delivering a valid authorization code (legitimately obtained, perhaps by redirecting a real user through their own authorization flow) to your client's redirect endpoint. Your client would then use that code — which is genuine — to fetch tokens belonging to a victim. state prevents this because the delivered state won't match what your client stored.

    The RFC for native apps (RFC 8252) is explicit that both should be used. RFC 7636 itself doesn't mandate state, but there's nothing in either document suggesting they're alternatives. Think of them as protecting against different directions of attack: state validates the authorization response, PKCE validates the token exchange.


    8. PKCE with a client secret — does the RFC say anything?

    RFC 7636 was written primarily for public clients. The question of whether PKCE is meaningful when a client secret is also present isn't addressed directly, but the threat model makes the answer clear.

    A confidential client — one that can securely hold a secret — can already prove its identity at the token endpoint through client authentication. An attacker who intercepts the authorization code can't complete the token exchange without the client secret. So in that scenario, PKCE adds a second layer of protection against a different threat: an attacker who has also compromised the client secret (which for native apps, as the RFC notes, is essentially anyone who has the binary).

    The RFC says authorization servers "MAY" accept clients that don't implement PKCE. For confidential clients where the secret is genuinely secret, omitting PKCE is a defensible trade-off. For anything called a "native app" regardless of whether it has a configured client_secret, PKCE should be present — because the RFC is also explicit that secrets embedded in distributed native app binaries cannot be considered confidential.

    The practical upshot for server implementors: don't skip PKCE verification just because client authentication succeeded. If the client sent code_challenge in the authorization request, the server must verify the code_verifier in the token request, regardless of whether the client also authenticated with a secret. The presence of a code_challenge creates an obligation on the server side that client authentication doesn't discharge.


    PKCE's place in the OAuth ecosystem has expanded well beyond its original native-app mandate. OAuth 2.1 (the consolidating successor to RFC 6749) effectively makes PKCE mandatory for all authorization code flows, including browser-based apps. The mechanism is simple enough that there's no good reason not to use it everywhere — the computational cost of a SHA-256 hash is negligible, and the verifier storage burden is a handful of bytes for the duration of a single authorization session. Understanding what the RFC actually requires — particularly around entropy, the S256 transform, and mandatory server-side enforcement — means you can catch implementation gaps before they become incident reports.


    Sources

    More Articles