OAuth, one step at a time · Part 1
The stolen code that wasn’t enough—and the stolen token that raises the next question.
A fictional story. Real protocol behavior.
The PKCE flow adds a secret check to the OAuth 2.0 code exchange. In this guide, follow Maya’s printing app through an interactive diagram. Then see which attack PKCE blocks and which remaining risk leads to DPoP.
Maya has finally picked the photos for a family album. They are in her cloud photo account, and she wants to print them through an app on her phone.
The printing app offers “Connect your photo account.” Maya taps it. A browser opens the photo service’s own page, where she signs in and approves access to read her photos.
She has given the app permission. She has not given it her photo-account password. That separation is the point of this OAuth 2.0 flow.[1]
A moment later, the browser returns to the app. The thumbnails appear. Maya carries on choosing pages.
But pause at that handoff. What did the service return? And what would happen if someone else copied it?
First comes a code
In the authorization code flow, the service first returns a temporary authorization code. The app exchanges it for an access token. That token is what the app presents when requesting the photos.[1]
| Value | What the app does with it |
|---|---|
| Authorization code | Exchanges it with the account service for a token. |
| Access token | Presents it to the photo API to request permitted data. |
Maya has already signed in. Why doesn’t the app get the token now?
Her sign-in tells the photo service who she is. Her approval tells it what the printing app may access. The service still needs to deliver the token to the app that is entitled to receive it.
That involves two separate conversations.
First, Maya talks to the account service. She signs in and approves access in her browser. The service sends that browser back to the printing app with a temporary authorization code.
Then, the app talks to the account service. The app sends the code directly to the service’s token endpoint—the address where it exchanges codes for tokens. The service checks the code and the app’s request before issuing the access token.[1]
The code connects those two conversations: Maya granting permission, and the app collecting the token that represents that permission.
Why not send the token back through the browser immediately? The redirect is a handoff where values can be exposed—for example, if its URL is recorded in an unsafe log or the return is intercepted. If an access token leaks there, someone may be able to use it directly to request Maya’s photos.[6]
Returning a short-lived code gives the service another check before releasing the usable token. A code can be redeemed successfully only once. With PKCE, exchanging it also requires a matching secret prepared by the app, which we’ll explore next. Someone who copies only the code cannot complete that exchange.[1] [3]
The extra step protects the delivery of the token to the app, after Maya has signed in and granted permission.
What if someone copies the code?
For our story, imagine an unsafe troubleshooting log exposes the returning code while it is still unused. Someone with unauthorized access copies it. The legitimate app can still receive the original response; this is a copied-code scenario.
Our mobile app has a public app identifier, not a secret it can reliably hide in every installed copy. In an older flow without PKCE, the copied code could be enough to obtain a token if the attacker can satisfy the remaining exchange checks. This is a specific scenario, not a claim that every copied code works everywhere.[2]
The question is now smaller and clearer: can the service require something the attacker did not copy?
The app prepared something earlier
Rewind to before Maya’s browser opened.
The app made a fresh, unpredictable secret for this attempt. Its protocol name is the code verifier. It kept that secret and calculated a second value from it: the code challenge.
The app uses S256, the recommended secure way to create the challenge. It turns the verifier into a one-way fingerprint. The app can calculate the fingerprint easily, but someone seeing only that fingerprint should not be able to work backward to the random secret.
The app sends the challenge at the beginning. Later, when it exchanges the code, it supplies the verifier. The service calculates the fingerprint again and compares it with the challenge associated with that code.[3]
This is PKCE, short for Proof Key for Code Exchange, pronounced “pixy.”
The copied code is only one piece. The exchange also needs the verifier that matches the challenge sent at the start.
The verifier is generated by the app. Maya never memorizes it or types it. It is also different from the code returned by the service.[4]
Follow the PKCE flow, from the beginning
Before Maya starts, the app developer registers and configures the printing app. Maya’s attempt then involves four participants: the printing app, her browser, the account service, and the photo API. The account service handles approval and issues the code and token; the photo API checks access and serves the pictures. The attacker appears in the two copied-value scenarios.
Follow 0. Register the app, then 1. Obtain a token. In the interactive version, “Continue to Maya’s attempt” moves between them. 2. Copy the code shows PKCE blocking an attacker who copies an unused code but lacks the matching verifier. 3. Copy the token starts after a separate successful exchange and shows the remaining risk: a copied bearer token may still work at the photo API. That is the gap the next article addresses with DPoP. Each scenario stands on its own; the later chapters do not reuse a code already consumed in chapter 1.
Two kinds of preparation
Set up before users begin: the developer registers the app and its allowed return address. The service assigns an app identifier (client_id). The return address (redirect_uri) is where the browser comes back after approval.
Fresh for each attempt: the app creates a verifier, its challenge, and state. After Maya approves, the service creates a code. These are not permanent registration settings.
Start with chapter 0, then choose chapter 1. Select a diagram step to read its explanation; “Inspect this message” shows the example values.
Who does what?
- App developer: sets up the app before users begin.
- Printing app: the app on Maya’s phone that wants to read her photos.
- Maya’s browser: takes her to the photo account’s sign-in and approval page, then brings her back to the app.
- Account service: handles app registration, Maya’s sign-in and approval, and the code-to-token exchange.
- Photo API: the service the app asks for photos after it has a token.
- Attacker: someone who copies a code or token in the fictional leak scenarios.
Before Maya uses the app: the developer registers it. This setup is not repeated for each sign-in; it can be updated later.
The developer registers the printing app.
Before users begin, the developer registers the mobile app and its allowed return address with the account service.
Illustration only. Each chapter starts a separate scenario; it does not reuse a consumed code. No real registration, sign-in, or API call takes place. Registration and code checks follow the OAuth specification.
Read the full sequence without animation
Register the app before users begin
- The developer registers the mobile printing app and its allowed return address with the account service. Maya does not perform this setup.
- The service keeps the registration so it can check future authorization requests.
- The service assigns client_id, an app identifier. It is public and cannot prove the caller’s identity by itself.
- The developer configures the app with that identifier and an allowed redirect_uri. These settings usually stay the same across attempts and can be updated later. Verifier, challenge, state, and authorization code are fresh for each attempt.
Obtain a token
- The printing app creates and saves a fresh verifier.
- The app calculates its S256 challenge from the verifier.
- The app creates a separate random state value and saves it for this pending attempt.
- The app opens the browser with a URL containing the challenge, method, and state. The verifier stays with the app.
- The browser sends that authorization request to the account service.
- The service finds the registered app using client_id and checks that the requested redirect_uri is allowed for it. It also checks the flow and requested permissions. An invalid app or return address stops the flow.
- The service keeps the challenge and S256 method with the pending request.
- Maya signs in and approves photo-reading access.
- The service creates a short-lived code and links it to the challenge, method, app, return address, and approved access.
- The service redirects the browser with the code and the same state it received.
- The browser returns the code and state to the printing app.
- The app compares the returned state with the value it saved. A mismatch stops the flow; a match identifies the pending attempt and its verifier.
- The app sends the code and verifier directly to the token endpoint, along with its app identifier and the same return address.
- The service checks that the code is valid, unexpired, and unused, and retrieves the details linked to it.
- The service compares the client_id with the app this code was issued for and checks that redirect_uri exactly matches the original authorization request. The identifier is public, so the verifier check is still needed. The return address is checked for consistency; the token is delivered in the direct exchange response. Required client authentication must also pass where applicable.
- The service calculates the S256 fingerprint of the received verifier.
- The service compares that fingerprint with the challenge linked to the code. A missing, invalid, or nonmatching verifier means no token is issued.
- When all checks pass, the service completes the one-time redemption. The code cannot be used successfully again. These steps illustrate the checks, not a required internal processing order.
- The service returns a bearer access token directly to the app for the approved photo-reading access.
- The app presents the token to the photo API without the PKCE verifier.
- The API validates access and returns photos.
Copy the code
- A challenge is already associated with Maya’s pending code.
- An unsafe callback log exposes a copy of the still-unused code.
- The attacker submits that code and a different verifier.
- The account service finds that the computed challenge does not match.
- The service rejects the token request with invalid_grant.
Copy the token
- A separate, legitimate PKCE exchange has already completed.
- The app presents its bearer token to the photo API.
- An unsafe request log exposes a copy of that token.
- The attacker presents the copied bearer token to the photo API.
- If the token is valid and no other control blocks access, the API can return photos. PKCE is not checked at this API call.
Chapter 0: register the app before Maya starts
The developer registers the mobile printing app and its allowed return address with the account service. The service keeps that registration and assigns an app identifier, called client_id. The developer configures the app with that identifier and an allowed redirect_uri—the address where the browser can bring Maya back after approval.[1]
In our example, the identifier is album-printer-mobile and the allowed address is com.example.albumprinter:/oauth/callback. These settings usually stay the same across attempts and can be updated later. The identifier is public, not a password. Registration alone gives the app no access to Maya’s photos; she still needs to approve that access.
Chapter 1: follow one fresh attempt
The explanation below follows the same 21 steps as the diagram. The verifier, its challenge, and state belong to this attempt. The account service creates the authorization code after Maya approves.
Prepare the PKCE flow (steps 1–5)
Create a fresh verifier. The app makes and saves an unpredictable secret for this attempt. Nothing has been sent to the account service yet.
Calculate the challenge. The app uses S256 to create a one-way fingerprint of the verifier. This fingerprint is the challenge; it is not the secret itself.
Create and save state. The app creates a separate random value to identify this pending attempt. It saves that value with the attempt’s verifier so it can check the returning response.
Open the authorization URL. The app opens Maya’s browser with its registered identifier, an allowed return address, the requested photo-reading permission, the challenge, the S256 method, and state. The verifier stays with the app.
Send the request to the account service. The browser delivers that URL over HTTPS. It passes along the app’s challenge and state; it does not create them.
Check the request and return a code (steps 6–10)
Check the registered app and return address. The service uses
client_idto find the app’s registration and checks thatredirect_uriis allowed for it. It also checks the requested flow and permissions. An invalid app or return address stops the flow.Keep the challenge and method. The service keeps the challenge and S256 method with the pending authorization request. It has the fingerprint, but has not received the verifier.
Maya signs in and approves. Maya interacts with the account service in her browser. She approves photo-reading access. The printing app does not receive her photo-account password.
Link a new code to the challenge. The service creates a short-lived authorization code and links it to the challenge, S256 method, app identifier, return address, and access Maya approved. The code has not yet been exchanged.
Redirect with the code and the same state. The service sends the browser to the app’s return address. It includes the code and returns the state value unchanged. It does not use state for the PKCE fingerprint comparison.
Return to the app and request a token (steps 11–15)
Return to the printing app. The browser and phone hand the code and state to the app. No access token is carried in this return.
Check the returning state. The app compares it with the value it saved. A mismatch stops the flow. A match identifies the pending attempt and the verifier to use for its exchange.
Exchange the code and verifier. The app sends them directly to the service’s token endpoint over HTTPS, together with its app identifier and the same return address. The browser does not forward this request. State has already been checked by the app.
Check that the code can still be used. The service retrieves the details linked to the code and checks that it is valid, unexpired, and unused. If it fails these checks, the service does not issue a token.
Match the app and original return address. The request’s
client_idmust match the app the code was issued for, and itsredirect_urimust exactly match the original authorization request. The identifier is public, so matching it is not enough without the verifier check. The address is checked for consistency; the token is not redirected there. Any required client authentication must pass too.
Verify the secret, then use the token (steps 16–21)
Calculate a fingerprint from the received verifier. The service uses the S256 method linked to the code to calculate a new fingerprint from the verifier in this token request.
Compare with the challenge saved earlier. The service compares the new fingerprint with the challenge linked to this code. They must match. A missing, invalid, or nonmatching verifier means no token is issued.
Complete the one-time exchange. Once all required checks pass, the service completes the successful redemption so the same code cannot obtain another token.
Return the bearer access token. The service returns the token directly in its response to the app’s token request. In our example, it permits photo reading and lasts 15 minutes.
Request photos with the token. The app sends its bearer token to the photo API. It does not attach the PKCE verifier to this request.
Return permitted photos. The API checks the token’s validity and permissions, then returns the photos. PKCE protected the code exchange; it did not bind this bearer token to a separate key.
These steps explain the checks in our illustration, not a required internal processing order for every provider. All required checks must pass before a token is issued. The code can be successfully redeemed only once.[1] [3]
This combines the ordinary code flow with PKCE’s extra check. Native apps should use an external browser or a browser-provided authorization session for this interaction, rather than a web view controlled by the app.[4] [5] [2]
HTTPS protects the connection used in this PKCE flow. For another mobile security layer, read how certificate and public key pinning work. Pinning concerns which server the app trusts; it does not replace PKCE.
What the messages look like
These are four key messages from 1. Obtain a token, using exactly the same example values as the diagram. Their numbers identify the messages below, not individual animation steps. You do not need to memorize the fields; follow where each value goes. They are fictional examples, not requests to a real provider. Line breaks make them readable; an actual request must be correctly encoded.
1. Open the authorization page.
client_id identifies the registered printing app, and redirect_uri gives its allowed browser return address. scope=photos.read asks for photo-reading permission. The app adds this attempt’s state and complete S256 challenge. The verifier is absent. The service checks the app and allowed address, then keeps the challenge and method for the pending request.
GET /authorize?
response_type=code
&client_id=album-printer-mobile
&redirect_uri=com.example.albumprinter%3A%2Foauth%2Fcallback
&scope=photos.read
&state=demo-attempt-01
&code_challenge=uYxnNUiijNGPsFR42gbfCBp66EIPeS_wQslDRDeLbzQ
&code_challenge_method=S256
Host: accounts.example.com
2. Receive the browser’s return.
After Maya approves, the service creates a code and links it to this attempt’s challenge, method, app, return address, and approved access. It redirects the browser to the allowed address with the code and the same state the app sent:
com.example.albumprinter:/oauth/callback
?code=demo-code-01
&state=demo-attempt-01
3. Exchange the code.
The app first checks that the returned state matches the saved value; a mismatch stops the flow. With a match, it sends the code, matching verifier, app identifier, and same return address directly to https://accounts.example.com/token. State is not part of this token request. Here is the verifier whose fingerprint appeared in message 1:
POST /token HTTP/1.1
Host: accounts.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id=album-printer-mobile
&redirect_uri=com.example.albumprinter%3A%2Foauth%2Fcallback
&code=demo-code-01
&code_verifier=v20Pc6b0xsaZzUlnL-rEB01H6VRR49rmQqOwkJkR0hk
4. Receive the token.
The service checks that the code is valid, unexpired, and unused; that the app identifier and original return address match; and that the received verifier produces the challenge linked to the code. When all required checks pass, it completes the one-time exchange and returns the token directly to the app—not through another browser redirect:
{
"access_token": "demo-access-token-01",
"token_type": "Bearer",
"expires_in": 900,
"scope": "photos.read"
}
The permission name and 15-minute lifetime are choices for this example. The code, token, and state are demonstration labels, not usable credentials. Real state values must be unpredictable and tied to the correct attempt. The published verifier/challenge pair is complete and mathematically valid, but must never be reused in a real flow.[5]
Now try the copied code
Choose 2. Copy the code. This is an alternative scenario that starts before successful redemption: a challenge is already linked to Maya’s still-unused code, and an unsafe callback log exposes a copy of that code. The legitimate app can still receive the original response. We are not trying to reuse the consumed code from chapter 1.
The attacker copies demo-code-01 and also knows client_id=album-printer-mobile and the registered return address. They can repeat those public details in their request, but none of them reveals the matching verifier.
The attacker submits the unused code with the different verifier shown in the diagram. The service calculates that verifier’s S256 fingerprint and compares it with the challenge already linked to Maya’s code. They do not match, so no token is issued. The PKCE mismatch produces an invalid_grant error.[3]
Using a verifier from the attacker’s own authorization attempt does not help either: it matches a different challenge, associated with a different code. That is why the pairing has to be specific to each attempt.[6]
PKCE has done its job: the copied code and public app details did not let the attacker obtain Maya’s token. This scenario does not promise that the legitimate app can retry the same code after the failed attack; providers may handle failed attempts differently.
Questions that usually come next
Keeping the PKCE verifier secret
Why not send the verifier at the beginning?
That would expose the secret on the authorization path. Sending its one-way fingerprint lets the service commit to the expected value without putting the verifier there. Using S256 keeps the verifier out of the initial authorization request.[6]
Does PKCE replace HTTPS?
No. The token request contains the verifier and the response contains a token. These still need transport protection. HTTPS also does not make an unsafe log safe: a secret can be exposed after a connection has delivered it.[7]
Where PKCE fits in the wider flow
Does this apply only to mobile apps?
No. Current OAuth security guidance requires PKCE for public clients and recommends it for confidential clients, including server-side web apps. A server-side client still performs its required client authentication. PKCE does not replace that authentication.[6]
What is state doing here?
The printing app creates a fresh random state value, saves it for the pending attempt, and sends it in the authorization URL. The browser carries it to the service, which returns the same value alongside the code. The app checks that it matches the saved value before exchanging the code. State is separate from the verifier, is not a registration setting, and is not sent in our token request. Correctly deployed PKCE can also protect against forged callbacks when server support is assured; state can additionally carry protected application context.[6]
Is PKCE the thing that signs Maya in?
No. Maya signs in with the account service. OAuth handles the app’s authorization to access photos; PKCE adds a check to the code exchange. Applications needing a standardized sign-in result commonly use OpenID Connect on top of OAuth.[8]
Other limits to keep in mind
What if the attacker gets the verifier too?
Then the extra secret is no longer missing. PKCE cannot promise protection against someone who has the code and matching verifier and can satisfy the other exchange checks. Protect the app’s pending authorization data and avoid logging either secret. PKCE is not evidence that a phone or app is uncompromised.[4]
Does refreshing a token use the same PKCE exchange?
No. A refresh request uses a refresh token, if one was issued. It is a different OAuth request, with its own security requirements. The original verifier is not a permanent password to append to every refresh or API request.[1]
A few minutes later, a different secret leaks
Choose 3. Copy the token. This chapter starts after a separate successful PKCE exchange: the legitimate app already has its token, and there is no pending code to redeem. It sends the same kind of photo request shown at the end of chapter 1:
GET /photos HTTP/1.1
Host: photos.example.com
Authorization: Bearer demo-access-token-01
An unsafe troubleshooting log at the photo API records the Authorization header. Someone with unauthorized access to that log copies the token and puts it in their own request.
There is no new sign-in or code exchange. Instead, the attacker goes straight to the photo API.
If the token is still valid, covers this API and permission, and no other control blocks the request, it can succeed. “Bearer” means that using the credential does not require proof of a separate cryptographic key.[7]
PKCE has not failed. The attacker has moved beyond the exchange it protects. The photo API does not ask for the original PKCE verifier alongside every request.
This is the boundary to remember: protecting how an app obtains a token does not automatically protect a copied token when it is used later.
The next question is about using the token
Can we require the caller to show something more than the token value?
That is where DPoP—Demonstrating Proof of Possession—comes into the next article. It can bind a token to an app-held key and require a signed proof when the token is used. Copying that bound token alone is then insufficient. The client and servers must support and enforce the mechanism.[9]
| PKCE | DPoP | |
|---|---|---|
| Check we focus on | A code exchange includes the matching verifier. | A token request or API call carries a valid proof from the appropriate key. |
| Copied value in our story | Authorization code. | Issued access token. |
| What the attacker is missing | The matching verifier. | The private key needed to make the matching proof. |
We keep PKCE in that next flow. DPoP adds another protection; it does not undo the reason for securing the code exchange.[9]
Maya can still finish her album. We still need to fix the logging mistake. And now we have the right question for the next chapter: what should happen when someone copies the token that the app already received?
Putting the PKCE flow into practice
For implementers: the details behind the story
Use a fresh, unpredictable verifier for each authorization attempt, and keep it with the app’s pending authorization data. An OAuth library should generate it according to the PKCE standard.
challenge = S256(verifier)S256 creates a one-way fingerprint of the verifier. The service calculates that fingerprint again from the verifier it receives and compares it with the challenge linked to the code.[3]
Server support must include enforcement: a missing or incorrect verifier must not bypass a challenge already associated with a code. Current guidance also addresses downgrade attacks. Use a maintained OAuth library and test wrong-verifier, missing-verifier, reused-code, state-mismatch, and concurrent-attempt handling in the actual deployment.[6]
Our native-app return address illustrates a private-use scheme. Where available, claimed HTTPS redirects provide useful protection against interception. Register the actual redirect correctly; the example address is not a deployment recommendation for every platform.[2]
Sources and further reading
The characters, incident, examples, illustrations, and explanations were created for this article. Protocol claims are supported by the sources below. Provider documentation illustrates behavior; the standards govern the general requirements.
- RFC 6749 — Authorization Code Grant; also §§2–2.2 and 3.1.2 for app registration and return addresses, and §§4.1.2, 4.1.3 and 6 for code use, the exchange, and refresh requests.
- RFC 8252 — OAuth 2.0 for Native Apps, especially §§4, 7 and 8.
- RFC 7636 — PKCE, especially §§4.1–4.6 and 7.
- Auth0 — Authorization Code Flow with PKCE.
- Microsoft — OAuth 2.0 Authorization Code Flow. Our example values are fictional; the parameter names follow the OAuth and PKCE standards.
- RFC 9700 — OAuth 2.0 Security Best Current Practice, especially §§2.1.1, 4.5, 4.7 and 4.8.
- RFC 6750 — Bearer Token Usage, especially §§1.2, 2.1 and 5.
- OpenID Connect Core — Introduction.
- RFC 9449 — DPoP, especially §§2, 5, 7 and 11.

Leave a Reply