Security · 12 min
PKCE
A phone app can’t keep a password of its own, so a stolen login code used to be as good as your account. PKCE ties each code to a secret that stays in the app until the very last step.
You install Stride, a running app, and tap “Sign in with Google”. A second later you are signed in, and Stride never saw your password.
In that second, one short string made a trip across your phone: a login code. Anyone who copies it on the way can sign in as you. This article follows that one code from the moment it is made to the moment it is spent. You will see exactly where it gets stolen, and how PKCE (Proof Key for Code Exchange, said “pixie”) makes a stolen code worthless.
The trip, without PKCE
Two parties are involved besides you: Stride, and the login server, the service that holds your password (Google’s, here). The rules they follow are called OAuth. Three words first, because the whole article depends on them.
Here is the trip, called the authorization code flow. Phone apps use it, and so do websites and single-page apps (websites whose code runs entirely in your browser, like most React apps):
- Stride opens the login server’s page in the browser. The address carries two settings: client_id=stride-app says which app is asking, and redirect_uri=stride://callback says where to send you back.
- You type your password on the login server’s own page. Stride never sees it.
- The login server redirects the browser to stride://callback?code=Xk8f… The code rides in that address.
- Stride sends the code straight to the login server, which swaps it for an access token.
- From then on, Stride sends the token with every request.
Why the detour through a code? Why not put the token in the redirect? Because the redirect travels through the browser, and addresses get copied: into history, logs, other apps. So the address carries only a short-lived, one-use code. The real key comes back over a direct HTTPS request from Stride to the server.
OAuth has other ways to get a token (the OAuth grant types concept covers them). This article stays with the code.
Who is allowed to swap the code?
Step 4 is the weak spot. When a code arrives, the server has to decide: is this really Stride, and is this the same login that started a moment ago? It does check that client_id and redirect_uri match the ones from step 1, but those are public. Anyone can copy them.
For a website with its own server, the classic answer is a client secret.
A secret only works if it stays secret. A website keeps it on its own server, where nobody else can look. Stride can’t: the app is a file installed on millions of phones. Anyone can download it, unpack it and read every string inside, the secret included.
A single-page app is worse. Its JavaScript is sent to every visitor, readable in the browser’s developer tools.
So OAuth calls apps like these public clients and gives them no secret at all. Without PKCE, the code alone is the proof. Whoever brings it gets the token.
Where the code gets stolen
Back on the phone. The redirect goes to stride://callback, an address that Stride registered with the phone so the phone knows which app to open.
Nothing stops another app from registering stride:// too. When two apps claim the same address, which one opens is up to the phone, not Stride. If the phone picks the fake app, the fake app reads the code and swaps it. Stride never even sees the code; your login just seems to fail, and you try again. The fake app needs no secret, because Stride has none.
A fake app is one route. The code also leaks when the full redirect address lands somewhere it shouldn’t: a server log, a crash report, a synced browser history. Here Stride gets the code too, and whoever swaps it first wins: the server only accepts a code once. Every route ends the same way: the attacker holds a valid, unused code.
A fresh secret for every login
PKCE’s idea: if the app can’t keep a secret for years, have it make a new one for each login and keep it for a few seconds. At the start of the trip, the app locks in that secret without showing it, so it can’t be changed later. At the end, over the direct connection, it reveals it.
To lock in a secret without showing it, PKCE uses a hash.
- Before opening the login page, Stride picks a random string of 43 to 128 characters. Each character is one of 66: 26 capitals, 26 small letters, 10 digits and - . _ ~. This is the verifier. It stays in Stride’s memory.
- Stride hashes the verifier with SHA-256 and writes the 32 bytes as 43 characters using base64url. That is base64, a common way to write bytes as text with 64 different characters, but with - and _ in place of + and /, which mean something special in addresses. This is the challenge.
- The login page address carries code_challenge=<the challenge> and code_challenge_method=S256, the name for “SHA-256, then base64url”. The login server writes the challenge down next to the code it issues.
- At the swap, Stride sends the code and the verifier. The server hashes the verifier and compares the result with what it wrote down. Match: token. No match: refused.
Here are the two values from the worked example in RFC 7636, the PKCE standard. (Internet standards are published as numbered documents called RFCs.)
| Value | Where it travels | |
|---|---|---|
| Verifier | dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk | Only in the last request, straight to the server |
| Challenge = base64url(SHA-256(verifier)) | E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM | In the login page address, in plain sight |
Play the attack, with and without PKCE
- Leave PKCE off and press Next step until step 6. At step 5 the attacker swaps the stolen code and gets a token. At step 6 Stride is refused: “code already used”.
- Switch to PKCE on. Step 1 shows the verifier and its challenge; step 2 shows the challenge in the login address.
- Go on to step 5. The attacker sends the challenge as its verifier; the server hashes it, gets a different string and refuses. At step 6 Stride sends the real verifier and gets the token.
- Press Start over to return to step 1, then press New verifier a few times. Each challenge looks unrelated to its verifier.
Guess first: The attacker saw the login address, challenge included. Why can’t it send the challenge as the verifier, or work the verifier out from it?
The server never compares what you send with the challenge directly. It hashes what you send first. Hashing the challenge gives yet another string, so it fails, which is step 5 in the demo. Working backwards fails too: SHA-256 is one-way, so the only way to find the verifier is to guess. 43 random characters, each one of 66, give 66 multiplied by itself 43 times: about 1 followed by 78 zeros. OAuth’s rules say a code should live 10 minutes at most. Even at a billion guesses a second, that is 600 billion guesses, a 12-digit number, nowhere near.
What the code means now
Walk the trip again with PKCE on. The challenge travels in the login address, but it is only a hash. The code travels in the redirect, but it is useless alone. The verifier is sent exactly once, at the very end, over HTTPS straight to the one server that needs it, never through the browser or an address bar. If a fake app grabs the redirect, its swap is refused. Your login fails and you try again, but the attacker never gets in.
PKCE changes what a code means. Without it, a code says “whoever holds this may have a token”. With it, a code says “only the app that started this login may have a token”. That answers the question from the start: is this the same login? The server still can’t tell who is really Stride, but it can tell whether this is the same login that started the trip.
The check, in code
Here is everything on both sides. The line to look for is the comparison on the server: it hashes the verifier and checks the result against the stored challenge.
# in the app
verifier = random_string(43) # letters, digits, - . _ ~
challenge = base64url(sha256(verifier))
open_login_page(client_id, redirect_uri, challenge, method = "S256")
# ...the redirect brings back code...
token = post("/token", code, client_id, redirect_uri, verifier)
# on the login server
on login_done(client_id, redirect_uri, challenge):
code = random_string(20)
saved[code] = { client_id, redirect_uri, challenge, used: false }
redirect_to(redirect_uri + "?code=" + code)
on token_request(code, client_id, redirect_uri, verifier):
entry = saved[code]
if entry is missing or entry.used: refuse
if client_id or redirect_uri don't match: refuse
if base64url(sha256(verifier)) != entry.challenge:
refuse # OAuth’s error name for a bad code: "invalid_grant"
entry.used = true
return new_access_token()Not only for phones
PKCE was written in 2015 for phone apps. It fixes more than that:
- Single-page apps have Stride’s problem exactly: no secret can hide in JavaScript, so PKCE is the only thing tying the code to the app.
- Desktop and command-line tools that open a browser to sign you in are public clients too.
- Websites with a real client secret still benefit. Say an attacker has stolen a code made for your login. In their own browser, they start a login on the real website, and when the redirect comes back they replace their code with yours. The website can’t tell, trades your code for a token using its real secret, and the attacker is in, as you. With PKCE, the website made a verifier for the attacker’s login, but your code is tied to the challenge from your login, so the trade fails.
That is why RFC 9700, the current OAuth security advice (2025), says every public client must use PKCE and recommends it for apps with a secret too. OAuth 2.1, a draft that folds this advice into the main standard, makes PKCE a normal part of the code flow for every client.
So when you add “Sign in with…” to a phone app or a single-page app: make a fresh verifier for every login, send only its S256 hash at the start, send the verifier only in the final swap, and check that your login provider refuses a swap without it. A stolen code, or a stolen challenge, then buys an attacker nothing.
Remember
- Without PKCE, a login code works like cash: whoever holds it gets the token.
- Phone apps and single-page apps can’t keep a client secret: anyone can unpack them.
- PKCE: make a fresh random verifier, send its SHA-256 hash first, reveal the verifier only at the swap.
- A stolen code or challenge is useless without the verifier, and a hash can’t be run backwards.
- Use S256, for every kind of app: RFC 9700 and OAuth 2.1 expect it.