Applied Cryptography for Developers Module 11 – Tokens, OTPs and passkeys
PKCE, DPoP and certificate-bound tokens
Check a PKCE challenge against RFC 7636, verify DPoP proofs with a replay cache, and bind an access token to a key or a certificate with DPoP or mutual TLS.
What you will learn
- Generate and verify PKCE S256 challenges and check them against RFC 7636
- Verify DPoP proofs against the request and the token, with a replay cache
- Explain how DPoP and mutual TLS bind an access token to a key or a certificate
- Choose between bearer, DPoP-bound and certificate-bound tokens for a given client and server setup
Before you start
What you’ll learn · Before you start
What you will learn
- Generate and verify PKCE S256 challenges and check them against RFC 7636
- Verify DPoP proofs against the request and the token, with a replay cache
- Explain how DPoP and mutual TLS bind an access token to a key or a certificate
- Choose between bearer, DPoP-bound and certificate-bound tokens for a given client and server setup
Before you start
On this page
An OAuth access token is a bearer secret, so a copy from a log, a proxy or a crash report works for anyone until it expires, often an hour. The authorization code that comes before it crosses the browser in an address, where other software on the device may read it. This lesson is about three ways to make a copy useless on its own.
PKCE ties an authorization code to a secret that only the app that started the login holds. DPoP ties an access token to a key pair, with a fresh signed proof for every request. Certificate-bound tokens, which use mutual TLS, tie an access token to the client’s TLS certificate. Each runs here against the examples printed in RFC 7636, RFC 9449 and RFC 8705.
PKCE: a stolen code works only for the app that holds the verifier
In the authorization code flow the person signs in at the authorization server, and the browser carries a short-lived code back to the app. Whoever holds the code can ask the token endpoint for tokens. PKCE, short for proof key for code exchange and defined in RFC 7636, closes that gap. Before the request, the app draws a random The random secret that an app draws for one login in PKCE and keeps to itself until it asks for tokens., 32 random bytes written as 43 characters. It sends only the SHA-256 hash of the verifier, the code challenge, with the authorization request, and it sends the verifier itself later, with the code, to the token endpoint. The server hashes the verifier it receives and compares the result with the challenge it stored. RFC 9700, the best current practice for OAuth, requires PKCE for public clients and recommends it for confidential ones. Step through one login:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The client draws a secret. Before it sends anyone anywhere, the app draws 32 random bytes and writes them as the 43-character code verifier. It keeps the verifier in memory for this one login.
- It sends only a hash. The authorization request carries the code challenge, the Base64url SHA-256 hash of the verifier, and the method S256. The hash cannot be turned back into the verifier.
- The server stores it. After the person signs in, the server issues a code and writes the challenge beside it. The code alone is not yet enough to get tokens.
- The code comes back by redirect. The code returns through the browser as part of an address, and software on the same device may be able to read an address of this kind.
- A copy is tried. The other app asks the token endpoint for tokens with the copied code. It has no verifier, because the verifier never left the client.
- The client proves itself. The client sends the code and the verifier. The server hashes the verifier, finds the stored challenge and, because they match, issues the tokens to the app that started the login.
The program uses the example of RFC 7636 Appendix B. Its 32 bytes become a 43-character verifier, and the SHA-256 hash of that text is the challenge the RFC prints:
// PKCE with the example of RFC 7636, Appendix B: 32 random bytes become the code verifier, and the SHA-256 hash of the
// verifier, written in Base64url, is the code challenge. The challenge travels in the authorization request; the
// verifier only goes to the token endpoint, with the code.
import { createHash } from 'node:crypto';
// The RFC's 32 bytes. A real client draws them from the CSPRNG for every login (crypto.randomBytes(32)).
const bytes = Buffer.from([116, 24, 223, 180, 151, 153, 224, 37, 79, 250, 96, 125, 216, 173, 187, 186, 22, 212, 37, 77, 105, 214, 191, 240, 91, 88, 5, 88, 83, 132, 141, 121]);
const verifier = bytes.toString('base64url');
const challenge = (v) => createHash('sha256').update(v, 'ascii').digest('base64url');
const published = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
console.log(`random bytes ${bytes.length}`);
console.log(`code_verifier ${verifier} (${verifier.length} characters)`);
console.log(`code_challenge ${challenge(verifier)} (${challenge(verifier).length} characters)`);
console.log(`the RFC's value ${published} same: ${challenge(verifier) === published}`);
console.log(`syntax of RFC 7636 section 4.1 (43 to 128 of A-Z a-z 0-9 - . _ ~): ${/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)}`);
console.log('');
// The token endpoint stored the challenge with the code. A copy of the code is no use without the verifier.
const stored = challenge(verifier);
const accepts = (candidate) => challenge(candidate) === stored;
console.log(`token request with the client's verifier ${accepts(verifier) ? 'tokens issued' : 'invalid_grant'}`);
console.log(`token request with the code and no verifier ${accepts('') ? 'tokens issued' : 'invalid_grant'}`);
console.log(`token request with the challenge as verifier ${accepts(stored) ? 'tokens issued' : 'invalid_grant'}`); Output
random bytes 32 code_verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk (43 characters) code_challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (43 characters) the RFC's value E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM same: true syntax of RFC 7636 section 4.1 (43 to 128 of A-Z a-z 0-9 - . _ ~): true token request with the client's verifier tokens issued token request with the code and no verifier invalid_grant token request with the challenge as verifier invalid_grant
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node pkce_vector.mjs
Both values match the RFC. The challenge is one-way, so the authorization request, which another app may read, carries something that cannot be turned back into the verifier. A token request with the copied code and no verifier, or with the challenge in place of the verifier, was refused. PKCE protects the code. DPoP and certificate-bound tokens protect the access token that the code is exchanged for. A token bound with DPoP works only with a signature, made for each request, by the key it was issued to. A token bound to a certificate works only on a mutual TLS connection, where the client presents that certificate during the handshake as well as the server.
Checkpoint
Check: What PKCE sends and why a copied code fails
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why is a bearer access token a problem, and what are the three ways to bind it?
A copy from a log, a proxy or a crash report works for anyone until it expires, often an hour. PKCE ties an authorization code to a secret that only the app that started the login holds. DPoP ties an access token to a key pair, with a fresh signed proof for every request. Certificate-bound tokens, using mutual TLS, tie an access token to the client's TLS certificate.
-
How does PKCE protect an authorization code?
Before the request the app draws a random code verifier, 32 random bytes written as 43 characters, and sends just the SHA-256 hash of it, the code challenge, with the authorization request. It sends the verifier itself later, with the code, to the token endpoint, and the server hashes it and compares. RFC 7636 defines it; RFC 9700 requires it for public clients and recommends it for confidential ones.
-
Why can the PKCE challenge travel in an address another app may read?
It is one-way: the hash cannot be turned back into the verifier. A token request with the copied code and no verifier, or with the challenge in place of the verifier, was refused. RFC 7636's example matched: the 43-character verifier hashes to the challenge the RFC prints.
-
What does PKCE protect, and what do DPoP and certificate binding protect?
PKCE protects the authorization code. DPoP and certificate-bound tokens protect the access token it is exchanged for: a DPoP-bound token works only with a signature, made for each request, by the key it was issued to; a certificate-bound token works only on a mutual TLS connection, where the client presents that certificate in the handshake.
PKCE on the server: what to enforce, and the downgrade it must refuse Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- DPoP: what the client signs and the checks a server runs
- Check a DPoP proof against the request and the token
- Certificate-bound tokens: the thumbprint in the token must match the TLS connection
- Choosing between bearer, DPoP-bound and certificate-bound tokens
- Practise: bind, check and refuse
- Interview questions
- What does PKCE stop, what is the code verifier, and what does the authorization server store?
- Your authorization server supports PKCE but keeps it optional for older clients. A review says it can still be attacked. Explain how, and change the server so that old clients keep working where they can.
- Your API accepts DPoP-bound access tokens. A log leak exposes some tokens, and some captured requests with their proofs. Explain what a finder can and cannot do, which checks keep it that way, and how you stop replay across many servers.
- Your team wants certificate-bound tokens, but all TLS ends at a cloud load balancer and the services behind it learn the client certificate from a header. What must be true for this to be safe, and what breaks at the next certificate renewal?
- Key takeaways
1 diagram · 3 runnable examples (JavaScript) · 1 exercise with sample tests · 3 interview answers · about 43 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- certificate-bound token
- An access token tied to a client's TLS certificate through the SHA-256 hash of the certificate (RFC 8705). It is accepted only on a connection that presents that certificate.
- code verifier
- The random secret an app draws for one login in PKCE: 43 to 128 characters, usually 32 random bytes in Base64url. Only its SHA-256 hash, the code challenge, goes with the authorization request.
- DPoP
- Demonstrating Proof of Possession (RFC 9449). The client signs a short proof for each request with a private key, and the access token is bound to the matching public key.
- DPoP proof
- The signed JWT a DPoP client sends in the DPoP header. Its header holds the client's public key and its claims name the method, the address, the time, a unique ID and, with a token, the token's hash.
- JWK thumbprint
- The SHA-256 hash of a JWK's required members in alphabetical order (RFC 7638), written in Base64url. It names a key without carrying it; DPoP stores it in a token as jkt.
- mutual TLS
- TLS in which the client proves its identity with a certificate as well as the server, so each side authenticates the other during the handshake.
- PKCE
- Proof Key for Code Exchange (RFC 7636). The app sends a hash of a random secret with the authorization request and the secret itself with the code, so a copied code is refused.
- proof of possession
- A check that the sender of a request holds a key or a secret, not only a copy of a token. Without one, anyone holding a bearer token can use it.
- sender-constrained token
- An access token that works only for the client it was issued to, because the server also checks a key or a certificate of the sender. DPoP-bound and certificate-bound tokens are of this kind.
References
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients (Internet Engineering Task Force (IETF))
- RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP) (Internet Engineering Task Force (IETF))
- RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens (Internet Engineering Task Force (IETF))
- RFC 9700: Best Current Practice for OAuth 2.0 Security (Internet Engineering Task Force (IETF))
- RFC 7638: JSON Web Key (JWK) Thumbprint (Internet Engineering Task Force (IETF))
- RFC 9440: Client-Cert HTTP Header Field (Internet Engineering Task Force (IETF))
- OWASP Application Security Verification Standard 5.0, V10 OAuth and OIDC (requirements 10.3.5, 10.4.2, 10.4.3, 10.4.6 and 10.4.14) (OWASP Foundation)
- Node.js 24 documentation: Web Crypto API and crypto.X509Certificate (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress