Lesson 4 of 12 · 9 min read
OAuth 2.0 and PKCE
In this lesson
TL;DR: OAuth 2.0 (RFC 6749) lets a client application act on a resource owner's behalf at a resource server, using an access token from an authorization server, limited to approved scopes, without the client ever seeing the user's password. The authorization code flow returns a one-time code to a registered redirect URI, which the client exchanges for tokens. PKCE (RFC 7636) adds a secret the client generates per login, so redemption requires the verifier bound to that code. This does not authenticate a public app as a trusted client. The 2025 OAuth security best current practice (RFC 9700) says: use the code flow with PKCE, exact redirect URI matching, and no implicit or password grants.
Before you read
Pick an answer before you read further. The lesson below explains it, and guessing first, right or wrong, helps the explanation stay with you.
1A calendar app asks for your email password so it can read your meeting invites. What does OAuth change about this arrangement?
The problem OAuth solves
Before OAuth, an app that needed your data from another service asked for your password. That gave the app full, permanent access to your account, which you could only revoke by changing the password everywhere. OAuth replaces the password with a token: limited to specific permissions (scopes), time-bound, revocable, and issued without the app ever handling your credentials.
Note what OAuth is: delegated authorization. It answers "may this app access this resource for this user?" It does not, on its own, tell the app who the user is. Identity on top of OAuth is OpenID Connect.
The four roles
| Role | Example |
|---|---|
| Resource owner | You |
| Client | The app that wants access (web app, mobile app, CLI, AI agent) |
| Authorization server | The service that authenticates you and issues tokens |
| Resource server | The API that holds your data and accepts tokens |
The authorization code flow with PKCE
User + browser
Client app
Authorization server
Resource server
Client app to Client app
1
1. make code_verifier, hash to challenge
A fresh high-entropy secret for this login. The client sends only its SHA-256 hash, the code_challenge.
What each piece defends against:
| Piece | Defends against |
|---|---|
| Redirect to the authorization server | The client handling the user's password |
| Scopes and consent | The client getting more access than the user approved |
state | Cross-site request forgery: an attacker's code being attached to the victim's session |
| Exact redirect URI matching | Codes being sent to an attacker-controlled URL |
| Short-lived, single-use code | Replay of a captured code |
| PKCE verifier and challenge | Redemption of an intercepted code without the bound verifier |
| Token audience and scope checks | A token issued for one API being accepted by another |
Requirements for public and confidential clients
PKCE was designed for mobile and single-page apps, which cannot keep a client secret, because on those platforms a malicious app could register the same redirect scheme and intercept the code. It turned out to help confidential server-side clients too, by defeating code injection attacks. RFC 9700, the OAuth 2.0 Security Best Current Practice published in January 2025, requires PKCE for public clients and recommends it for confidential ones; the OAuth 2.1 specification, still an Internet-Draft in November 2026, makes it the default.
The same documents retire two older grants. The implicit flow returned tokens directly in the browser redirect, exposing them in history and to scripts; it is deprecated. The resource owner password credentials grant had the client collect the user's password, defeating OAuth's purpose; it should not be used.
The security best current practice, as a checklist
RFC 9700 (January 2025), the OAuth 2.0 Security Best Current Practice, updates the original specifications with what a decade of attacks taught. The rules interviewers expect you to know, with their strength:
| Rule | Level | What it stops |
|---|---|---|
| PKCE for public clients; authorisation servers support it and reject a verifier when no challenge was sent | MUST | Code interception, and downgrade by stripping the challenge |
| PKCE for confidential clients | RECOMMENDED | Code injection into a legitimate client's session |
| Exact string matching of redirect URIs (port flexibility only for native-app loopback) | MUST | Codes delivered to an attacker-chosen path or subdomain |
| No open redirectors at clients or authorisation servers | MUST | Codes and tokens forwarded through a redirect |
| Resource owner password credentials grant | MUST NOT | Apps collecting users' passwords |
| Implicit grant (tokens in the redirect) | SHOULD NOT | Tokens leaking through URLs, history and referrers |
| Sender-constrained access tokens (mTLS or DPoP) | SHOULD | Replay of stolen tokens |
| Audience-restricted access tokens; resource servers refuse tokens not meant for them | SHOULD / MUST | Token reuse across services |
| Refresh tokens for public clients sender-constrained or rotated | MUST | Use of stolen refresh tokens |
| A mix-up defence when a client uses several authorisation servers | MUST | Codes sent to the wrong server |
Mix-up attacks. A client that works with more than one authorisation server can be tricked into sending a code obtained from an honest server to an attacker-controlled one. RFC 9207 (2022) adds an iss parameter to authorisation responses; the client compares it with the issuer it started the flow with and rejects mismatches. Per-server redirect URIs are the fallback.
Pushed authorisation requests (PAR, RFC 9126). Instead of putting the authorisation request's parameters in a browser URL, where they can be tampered with or logged, the client posts them to the authorisation server over the back channel with client authentication and receives a single-use request_uri to put in the redirect. Servers can require PAR for every client.
Resource indicators (RFC 8707). The client names the API it wants a token for with a resource parameter, so the issued token's audience is that one API; the Model Context Protocol requires this for MCP servers.
OAuth 2.1 consolidates these practices into one document, but in November 2026 it was still an Internet-Draft (revision 17), not an RFC. Cite RFC 9700 for requirements, and "the OAuth 2.1 draft" for the consolidated profile.
Worked example: one mobile login, with the numbers
- The app generates 32 random bytes, encodes them as a 43-character base64url
code_verifier, and computes the challenge as base64url(SHA-256(verifier)), also 43 characters. - It opens the system browser to the authorize endpoint with
response_type=code,code_challenge,code_challenge_method=S256, a randomstate,resource=https://api.bank.exampleand the claimed redirecthttps://app.bank.example/cb. - The authorisation server stores the challenge with a single-use code that lives 60 seconds (a design choice, not an RFC number) and redirects back with the code, the
stateandiss. - The app checks
stateandiss, then posts the code and verifier to the token endpoint. - It receives a 5-minute access token whose audience is the banking API and a rotating, DPoP-bound refresh token.
Two attacks fail along the way. A malicious app that intercepts the code cannot redeem it without the verifier: the token endpoint answers invalid_grant. An attacker who strips the challenge from the request, hoping the server will skip PKCE, fails because the server rejects a verifier-carrying redemption for a code that was issued without a challenge, which is exactly RFC 9700's anti-downgrade rule.
- 1
1 · code_challenge with S256
PKCE, RFC 7636
- Only the starter of the flow can redeem the code
- Server rejects downgrade attempts
Stops: intercepted and injected codes
- 2
2 · state
per-request random value
- Binds the response to this browser session
Stops: cross-site request forgery on the callback
- 3
3 · redirect_uri, exact match
registered in advance
- No wildcards, no open redirectors
Stops: codes sent to attacker paths
- 4
4 · resource
RFC 8707
- Token audience is one API
Stops: token reuse across services
- 5
5 · iss in the response
RFC 9207
- Client checks which server answered
Stops: mix-up between authorisation servers
Protect the code
PKCE and state make an authorization code useless to anyone but the session that asked for it.
OAuth for agents and tools
OAuth is the natural way to give an AI agent access to a user's accounts: the agent is a client, the user approves scopes, and the agent never holds the user's password. Two points matter:
- Scopes should match the task, not the whole API. An agent that drafts replies does not need delete permissions.
- Tokens must be issued for the server that receives them. The Model Context Protocol's authorization specification builds on OAuth 2.1 and requires MCP servers to accept only tokens issued for them and forbids passing a client's token through to other services (checked November 2026). Accepting tokens meant for someone else creates a confused deputy.
Compute S256, then test the transaction bindings
The PKCE transformation is base64url of the SHA-256 digest of the verifier's ASCII bytes, without padding. A fresh verifier must be generated for each flow and remain private to that flow. The following offline code generates one with the standard library. It prints only a matching result, not the verifier or a live credential.
import base64
import hashlib
import hmac
import secrets
verifier = secrets.token_urlsafe(32)
def s256(value):
digest = hashlib.sha256(value.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
challenge = s256(verifier)
assert len(verifier) == 43 and len(challenge) == 43
print(hmac.compare_digest(s256(verifier), challenge)) # => True
pending = {"browser": "session-A", "issuer": "https://id.example",
"state": "synthetic-state-A", "consumed": False}
def callback_matches(browser, issuer, state):
return (not pending["consumed"] and browser == pending["browser"]
and issuer == pending["issuer"] and state == pending["state"])
print(callback_matches("session-B", "https://id.example", "synthetic-state-A")) # => False
print(callback_matches("session-A", "https://id.example", "synthetic-state-A")) # => True
pending["consumed"] = True
print(callback_matches("session-A", "https://id.example", "synthetic-state-A")) # => False
This is a component fixture, not an OAuth implementation. The synthetic state label must be unpredictable in production. The authorization server separately enforces the code's client, redirect URI, challenge and single-use constraints. The client stores the expected issuer and endpoints with the transaction; it must not choose a token endpoint from an arbitrary callback field.
state, PKCE and an OIDC nonce protect different bindings. State can correlate a callback with a pending browser transaction. PKCE makes code redemption depend on the verifier. Nonce binds an ID token to the authentication request when used. One passing comparison does not establish the others. Follow the selected protocol profile and library instead of treating the three strings as interchangeable.
Start
Store browser binding, issuer, redirect and fresh secrets
Callback
Match the pending transaction and expected issuer
Exchange
Use the configured token endpoint and bound verifier
Complete
Validate the response and consume the transaction atomically
Start
Store browser binding, issuer, redirect and fresh secrets
The fixture uses a single-process mutation to illustrate consumption. Real concurrent callbacks need an atomic state transition in the shared transaction store. Define how an exchange timeout differs from a definitive rejection: an authorization server may have consumed the code even if the client did not receive the response. A retry policy must not attach a result from another pending login to recover from that uncertainty.
For acceptance testing, change one binding at a time: wrong browser session, wrong expected issuer, reused state, missing stored verifier and an expired transaction. Pair each with a successful flow through your configured library in an authorized test environment. The offline fixture checks only the local bookkeeping and S256 transformation.
Misconceptions interviewers listen for
- "PKCE is only for mobile apps." RFC 9700 recommends it for confidential clients too, against code injection.
- "State and PKCE are redundant." PKCE protects code redemption; state ties the callback to the browser session, unless the server's PKCE support is relied on for that purpose.
- "OAuth 2.1 is an RFC." It was still a draft in November 2026.
- "The implicit flow is fine with short-lived tokens." Tokens in URLs leak through history, logs and referrers; use the code flow.
- "Wildcard redirect URIs are fine if we own the domain." Any open redirect or takeover on that domain receives codes.
What if · interview follow-ups
Hold your answer as the constraints change
You are reviewing a company's OAuth deployment: a web app, a mobile app, partner integrations and 2,000 internal services calling each other. The interviewer probes one flow at a time.
Start here
What if the web app trusts two authorisation servers, and one is compromised?
Can the client tell which server answered?
Without a mix-up defence, the attacker's server can receive codes issued by the honest one. Require the iss parameter in authorisation responses and compare it with the issuer the flow started with, or use a distinct redirect URI per server, as RFC 9700 requires for multi-server clients.
Carry it forwardA client talking to several authorisation servers must verify which one responded.
Weak answerAssuming PKCE alone prevents mix-up.
Constraint changes
What if authorisation requests carry sensitive authorization details that end up in browser history and logs?
Does the request have to travel through the browser?
Use pushed authorisation requests: the client posts the parameters to the server over the back channel with client authentication and sends only a single-use request_uri through the browser, so nothing sensitive is in the URL and the parameters cannot be tampered with.
Carry it forwardMove sensitive or integrity-critical request parameters off the front channel.
Weak answerEncrypting query parameters by hand.
Constraint changes
What if a token issued for API A is also accepted by API B?
Whose audience is the token?
API B is accepting tokens not meant for it, which lets anyone holding an A token, including API A itself, act at B. Request tokens with a resource indicator so each has one audience, make every API check aud and refuse others, and consider separate signing keys or issuers per API family.
Carry it forwardEvery resource server checks it is the token's audience.
Weak answerRelying on scopes alone to separate APIs.
Constraint changes
What if a backend service needs to call a downstream API as the user?
May it forward the user's token?
No. Use token exchange to obtain a narrower token for the downstream API that names the user as subject and the service as actor, with its own audience and short lifetime. Forwarding the inbound token gives the downstream service a token it was not issued and erases the record of who acted.
Carry it forwardExchange tokens per hop; never pass a token beyond its audience.
Weak answerForwarding the user's access token downstream.
Constraint changes
What if 2,000 internal services authenticate with client secrets in environment variables?
What happens when one secret leaks, and how often do they rotate?
Each leaked secret is a long-lived credential. Use the client credentials grant with private_key_jwt or mutual TLS client authentication, short token lifetimes, per-service audiences and sender-constrained tokens, or better, workload identity federation so services present platform-issued identities and hold no static secret at all.
Carry it forwardMachine-to-machine OAuth should rest on keys or workload identity, not shared secrets.
Weak answerRotating the secrets more often as the whole answer.
Now defend it
As the defender, what would you monitor across the OAuth deployment?
Token endpoint failures for PKCE mismatches and invalid grants, authorisation responses with unexpected iss values, redirect URIs that fail exact matching, tokens presented to resource servers outside their audience, refresh-token reuse and family revocations, and any use of deprecated grants such as implicit or password.
Primary references
References for the mechanisms discussed above, accessed 6 November 2026:
How this shows up in interviews
"Walk me through the OAuth authorization code flow with PKCE, and what each part stops" is a standard identity question. Strong candidates draw the four roles, step through the flow, and attach an attack to each piece: state for CSRF, exact redirect matching for code theft, PKCE for code interception and injection. They also say clearly that OAuth is authorization, not authentication, and that implicit and password grants are obsolete.
Say it in 30 seconds: "OAuth lets a client act for a user at a resource server with a scoped, revocable token, without seeing the user's password. In the code flow the user logs in at the authorization server, a one-time code comes back to an exact registered redirect URI, and the client exchanges it for tokens. PKCE binds code redemption to the per-flow verifier, state stops CSRF, and the API checks audience and scopes. Best practice now is code plus PKCE for every client, no implicit or password grant."
Check yourself
Say or write your answer first, then reveal. Explaining it from memory, in your own words, is the skill an interviewer is checking.
1A mobile app uses the authorization code flow without PKCE. Another app on the same phone registers the same custom URL scheme. What can it do, and how does PKCE stop it?
2An agent platform asks users to grant full read-write access to their entire email account so the agent can summarise unread messages. What would you change?
3What does RFC 9700 require an authorisation server to do to stop PKCE downgrade?
4What is a mix-up attack, and what stops it?