Backend / Security / 05_oauth2_oidc.md

OAuth 2.0 and OpenID Connect

Updated 9 interview angles 15 min read source
On this page10
  1. The mechanism: front channel and back channel
  2. Choosing the flow
  3. Why the dead flows are dead
  4. OpenID Connect
  5. Where it breaks in production
  6. Access tokens: JWT or opaque
  7. Delegation downstream: token exchange
  8. The trade not taken
  9. Related
  10. Interview angle

OAuth 2.0 and OpenID Connect

Article.

There are six or so grant types and the honest answer is that you should use one of them, almost always. The interesting question is not “list the flows” — it is why the deprecated ones are deprecated, because that reasoning is what tells you which to pick when a new situation does not match a textbook case.

The rules live in three documents. RFC 6749 (2012) is the framework. RFC 9700 (January 2025), the OAuth 2.0 Security Best Current Practice, overrides much of it. RFC 10017 (August 2026) covers browser-based apps. OAuth 2.1 consolidates them but is still an Internet-Draft (draft-16, September 2026), so cite RFC 9700 for what is required today.

The mechanism: front channel and back channel

Every OAuth flow moves secrets between two parties, and there are only two paths available.

text
FRONT CHANNEL  - via the user's browser, as redirects
                 URLs land in history, referrer headers,
                 server logs, and any app registered for
                 the URI scheme. Nothing is authenticated.

BACK CHANNEL   - your server POSTs directly to the
                 authorization server. TLS, no
                 intermediary, client can prove identity.

The front channel is observable. The back channel is not. That single fact generates the rest of the protocol:

  • The authorization code flow exists only to move the token off the front channel. The code goes through the browser; the token is fetched over the back channel and never appears in a URL.
  • Implicit flow died because it put the access token in the front channel.
  • PKCE exists because a public client cannot authenticate itself on the back channel, so the code alone is not enough proof.
  • state exists because the front channel has no session integrity of its own.

Ask “which channel is this secret travelling on, and who can see that channel” and the design of every grant type follows.

Choosing the flow

Two questions decide it:

text
Is a human present?
  |
  +- no  -> Client Credentials
  |
  +- yes -> Can the client keep a secret?
              |
              +- yes  server-side app
              |       -> Auth Code + PKCE, confidential
              |
              +- no   SPA, mobile, CLI
                      -> Auth Code + PKCE, public
                      -> Device Code if no browser

That is the whole decision. “Can the client keep a secret” means: does the secret live somewhere the user cannot read? A secret shipped inside a mobile binary or a JavaScript bundle is not a secret — it is a published string — and this is the property that makes a client public, not the platform it runs on.

Flow When Status
Auth Code + PKCE anything with a user use this
Client Credentials service to service use this
Refresh Token renewing without re-prompting alongside
Device Code (RFC 8628) TVs, CLIs, no browser niche, correct
Implicit old SPA flow SHOULD NOT (RFC 9700); gone in 2.1
Password grant username straight to the app MUST NOT (RFC 9700)

The authorization code flow

text
1. app redirects to /authorize with client_id,
     redirect_uri, scope, state, code_challenge
2. user authenticates and consents
3. auth server redirects back: code, state, iss
4. app checks state and iss, POSTs to /token on
     the BACK channel: code, code_verifier,
     client_id (+ client authentication)
5. returns access_token, refresh_token, id_token
6. app calls the API:
     Authorization: Bearer <access_token>

The code is short-lived and single-use: RFC 6749 recommends at most 10 minutes, and most servers allow under one. A code presented twice MUST be refused, and the server SHOULD revoke the tokens already issued from it, because a second presentation means it leaked.

On its own the code is worthless: step 4 needs a PKCE verifier, and a confidential client also authenticates. The thing that travels through the browser cannot be redeemed by whoever intercepts it.

PKCE, and what it actually defends

python
import secrets, hashlib, base64

code_verifier = secrets.token_urlsafe(64)              # kept by the client
code_challenge = base64.urlsafe_b64encode(             # sent to /authorize
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()

# /token receives code_verifier; the server checks
# sha256(verifier) == the challenge it stored.

The attack it was invented for is mobile: redirect_uri is often a custom URI scheme, and a malicious app can register the same scheme and receive the redirect. It gets the code, and without PKCE that code is redeemable. With PKCE the interceptor has the code and not the verifier, so the exchange fails. PKCE authenticates the request, not the client: it proves the party redeeming the code is the party that started the flow.

It matters for confidential clients too, against code injection: an attacker who stole a victim’s code plants it in their own session at your legitimate client, which redeems it with its own secret and logs the attacker in as the victim. A secret does not help; only PKCE (or the OIDC nonce) binds the code to the session that started the flow.

What RFC 9700 actually requires (§2.1.1):

  • Public clients MUST use PKCE; for confidential clients it is RECOMMENDED, and a confidential OpenID Connect client MAY rely on nonce instead. The OAuth 2.1 draft makes it required for everyone.
  • S256, never plain. With plain the challenge is the verifier, so anyone who saw the authorization request can redeem the code.
  • The server rejects a code_verifier when the authorization request sent no code_challenge. Otherwise an attacker strips the challenge and downgrades the flow to no PKCE at all.

Native apps and CLIs

RFC 8252 sets the rules for anything that is not a web page:

  • Use the system browser (an in-app browser tab such as ASWebAuthenticationSession or Custom Tabs), never an embedded WebView. A WebView lets the app read the password, defeats the user’s existing session, and is exactly the phishing UI OAuth was meant to remove.
  • Redirect to a claimed https URL (Universal Links, App Links) where you can, since no other app can claim your domain; a private-use scheme like com.example.app:/cb works only with PKCE.
  • Desktop apps and CLIs listen on a loopback redirect, http://127.0.0.1:{port}/cb, on whatever port is free. Allowing any port there is the one exception RFC 9700 makes to exact redirect matching.
  • No browser at all (a TV, an SSH session): the device grant, RFC 8628.

Why the dead flows are dead

Implicit

The access token was returned in the URL fragment:

text
https://app.example.com/cb#access_token=eyJhbGci...&exp=3600

Every problem follows from that being the front channel: the token is in browser history, it can leak through referrer headers and any script on the page, there is no back-channel step at which the client could authenticate, and there is no safe way to issue a refresh token. It existed because CORS made the back-channel call hard in 2012, and that constraint is long gone. RFC 9700 says SHOULD NOT, and the OAuth 2.1 draft drops it.

Resource owner password credentials

The user types their password into your app and the app forwards it.

python
# The whole point of OAuth was to not do this.
{"grant_type": "password", "username": u, "password": p}

It defeats the premise: the client sees the credential, so nothing is delegated. It cannot do MFA, federated or social login, or a consent screen, and it trains users to type their identity provider password into third-party UI — the behaviour phishing depends on. RFC 9700 says MUST NOT; the only defensible use is a migration path off a legacy system, on a clock.

OpenID Connect

OAuth answers “may this app do this?” It does not answer “who is this?” OIDC adds that with an id_token — a JWT with standard claims — requested via the openid scope.

json
{
  "iss": "https://auth.example.com",
  "sub": "248289761001",
  "aud": "your-client-id",
  "exp": 1716003600,
  "nonce": "n-0S6_WzA2Mj",
  "email": "alice@example.com",
  "email_verified": true
}

The two tokens have different audiences and are not interchangeable. The id_token’s aud is your client id — it is for your app, to learn who signed in. The access_token’s aud is the API. An API that accepts an id_token has a privilege bug; see JWT pitfalls.

nonce is a random value the client sends in the authorization request and must find again in the ID token; it stops a replayed or injected ID token, and it is the confidential OIDC client’s alternative to PKCE. The user is iss plus sub, never email: trusting email without email_verified lets anyone who registers an unverified address at a permissive provider take over an account that links by email.

Where it breaks in production

Redirect URI matched by prefix

The single most damaging misconfiguration.

text
registered: https://app.example.com/callback

prefix match accepts:
  https://app.example.com/callback/../open?to=evil.com
  https://app.example.com.evil.com/callback

If an attacker can get the code delivered to a URI they control, PKCE is the only thing left standing. Require exact string matching, register every URI explicitly, and never accept wildcards. RFC 9700 also forbids open redirectors on the client and the authorization server: one /go?url= endpoint turns any exact match back into an exfiltration path.

state omitted

state is a random value the client stores and compares on return. Without it, an attacker completes a flow with their account, hands the victim the callback URL, and the victim’s browser silently links the attacker’s identity to the victim’s session — login CSRF. Bind state to the user’s session and reject on mismatch. It is CSRF protection only: it does not stop code injection, which is PKCE’s job.

Mix-up: a code sent to the wrong server

A client that trusts several authorization servers can be tricked into sending a code issued by the honest one to the attacker’s token endpoint, where it is captured and redeemed. Checking iss in the ID token is too late; the code is already gone.

The fix is the iss parameter in the authorization response (RFC 9207), compared with the server the flow started with before the code goes anywhere, plus a distinct redirect URI per authorization server. Pushed authorization requests (PAR, RFC 9126) go further and move the whole request to the back channel.

A stolen bearer token works for anyone

A bearer token is like cash: whoever holds it can spend it, from anywhere. RFC 9700 says access tokens SHOULD be sender-constrained: bound to a key the client holds, so a copied token is useless elsewhere. DPoP (RFC 9449) does it at the application layer with a signed proof on every request:

python
# A DPoP proof, decoded: a fresh JWT the client signs for every request
header = {"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk}
claims = {
    "jti": "e1j3V_bKic8-LAEB",                  # unique: the replay-cache key
    "htm": "POST",                              # this request's method
    "htu": "https://api.example.com/orders",    # and its URL, without query
    "iat": 1791734400,                          # fresh: seconds of validity
    "ath": b64url(sha256(access_token)),        # binds the proof to the token
}
# sent as:  Authorization: DPoP <access_token>
#           DPoP: <the signed proof>

The token carries cnf.jkt, the thumbprint of that public key (RFC 7638), and the API checks the proof’s signature, method, URL, age, ath and an unseen jti. A server nonce (DPoP-Nonce) stops proofs generated in advance. For server-to-server calls, mTLS-bound tokens (RFC 8705) do the same with the TLS client certificate, and the FAPI 2.0 profile used in open banking makes one of the two mandatory.

DPoP stops exfiltration, not in-page abuse: an XSS can still ask the page’s non-extractable WebCrypto key to sign proofs while it runs. Short lifetimes, audience-restricted tokens (RFC 9700 §2.3) and narrow scopes limit what a stolen token is worth either way.

Refresh tokens for public clients

A public client cannot authenticate when it refreshes, so RFC 9700 says its refresh tokens MUST be sender-constrained or rotated: each refresh issues a new token, and reuse of an old one revokes the family. Rotation has an operational catch — two tabs refreshing at once look like theft — which is why real implementations allow a short grace window (Refresh Token Rotation).

Tokens in the browser at all

RFC 10017 ranks three architectures for browser apps, most secure first:

  • Backend-for-frontend (BFF). A small server-side component performs the code exchange as a confidential client, holds every token, and gives the browser an HttpOnly, SameSite session cookie. The browser never possesses an OAuth token.
  • Token-mediating backend. The backend obtains tokens and keeps the refresh token, but hands the access token to the browser, which calls APIs directly. Cheaper to run, and an XSS can still steal access tokens.
  • Browser-based client. The SPA is a public client holding its own tokens. An XSS here gets everything, including fresh tokens it requests itself, which is why the RFC prefers the other two.
text
browser --cookie--> BFF --Bearer token--> API

The BFF costs a component and converts the problem back into session management, which is a solved problem with well-understood controls.

Client secrets leak

A confidential client’s client_secret ends up in a repo, a CI log or an environment dump, and it is the usual confidential-client breach. RFC 9700 recommends asymmetric client authentication instead: private_key_jwt (RFC 7523), where the client signs a short-lived assertion with a key that never leaves it, or mTLS (RFC 8705).

Between services, workload identity (Kubernetes service account tokens, cloud IAM roles) removes the long-lived secret altogether.

Scopes granted once, forever

scope=read write admin because it was easier during development, and nobody narrowed it. Scopes are the blast radius of a stolen token — request the minimum per client, and treat adding one as a change worth review.

JWKS caching and rotation

Cache the key set with a TTL. When a token arrives with a kid you do not know, re-fetch once, rate-limited, because the provider may have published a new key. A bad signature on a known kid is a bad token, not a reason to re-fetch; re-fetching on every failure turns forged-token traffic into a load generator aimed at your identity provider.

Access tokens: JWT or opaque

JWT Opaque
Validation local signature check introspection call
Revocation waits for expiry immediate
Cost none per request a round trip
Coupling none needs the auth server up

Choose from how fast a revocation must take effect, not from which is more fashionable. A JWT plus a blocklist is an opaque token with extra steps.

Delegation downstream: token exchange

An API that calls another API on the user’s behalf should not forward the token it received: that token’s audience is the first API, and forwarding it hands the second one more power than it needs. Token exchange (RFC 8693) trades it at the authorization server for a narrower token for the next hop:

http
POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the token API A received>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&resource=https://billing.internal
&scope=invoices:read

resource (RFC 8707, resource indicators) asks for a token audience-bound to one API. MCP’s authorization spec is built from these pieces: servers publish Protected Resource Metadata (RFC 9728), clients send resource, and a server must not pass a client’s token through to the APIs behind it (Building and securing MCP servers).

The trade not taken

The uncomfortable one: for a first-party app talking to your own API, you may not need OAuth at all.

OAuth solves delegation — letting software you do not control act on a user’s behalf without their password. If your React app and your FastAPI backend are the same product, the same team and the same deployment, there is no third party and nothing is being delegated.

A signed, HttpOnly, SameSite session cookie is simpler, has immediate revocation, no token storage question, no redirect URI to misconfigure and no refresh rotation to race.

What OAuth genuinely buys, and what should drive the decision:

  • Third-party clients, now or credibly soon.
  • Federated identity — social login, enterprise SSO, one IdP across many apps. See OIDC and OAuth 2.0 Flows.
  • Many services that must accept one credential without sharing a session store.

If none of those apply, adopting OAuth for a single first-party app buys an identity provider dependency and a larger attack surface in exchange for architecture that looks conventional. Being able to say that out loud is worth more in an interview than reciting the grant types.

Interview angle 9

  • “Which OAuth flow would you use?” — authorization code with PKCE for anything with a user, client credentials for service to service. Two questions decide it: is a human present, and can the client keep a secret — where “keep a secret” means somewhere the user cannot read, so a mobile binary and a JS bundle both count as public.
    • Follow-up: “Why is implicit deprecated?” — it returned the access token in the URL fragment, which is the front channel: browser history, referrer headers, any script on the page. The authorization code flow exists specifically to move the token onto the back channel, where only your server and the auth server can see it.
  • “What does PKCE actually defend against?” — code interception, originally on mobile where a malicious app can register the same custom URI scheme and receive the redirect. The interceptor gets the code but not the verifier, so the exchange fails.
    • Follow-up: “You have a client secret, so why still send PKCE?” — code injection: an attacker plants a stolen code in their own session at your client, which redeems it with its own secret. Only PKCE, or the OIDC nonce, binds the code to the session that started the flow. RFC 9700 requires PKCE for public clients and recommends it for confidential ones.
  • “An access token leaked from your logs. What limits the damage?” — its lifetime, its audience and its scopes, and above all whether it is sender-constrained. A bearer token works for whoever holds it; a DPoP- or mTLS-bound token is useless without the client’s private key.
    • Follow-up: “What does DPoP add that PKCE does not?” — PKCE protects one code exchange. DPoP binds every use of the token to a key, with a signed proof per request. It stops a copied token, not an XSS signing proofs in-page while it runs.

Under pressure

  • “What is state for?” — front-channel CSRF. Without it an attacker completes a flow with their own account and hands the victim the callback URL, silently linking the victim’s session to the attacker’s identity. It does not stop code injection; PKCE does.
  • “How should a redirect URI be validated?” — exact string match against a registered list, with no open redirectors on the domain. Prefix or wildcard matching delivers the code to the attacker, and then PKCE is the only control left. Loopback redirects for native apps may vary the port.
  • “Your client trusts two authorization servers and one is malicious. How does it steal a code?” — a mix-up attack: the client sends the honest server’s code to the attacker’s token endpoint. Check the iss authorization-response parameter (RFC 9207) before redeeming.
  • “Difference between OAuth and OIDC?” — OAuth is authorization, OIDC adds identity through an id_token. They are not interchangeable: the id_token’s audience is your client, the access token’s is your API, and an API accepting an id_token has a privilege bug.
  • “Where do you store tokens in an SPA?” — ideally nowhere. RFC 10017 ranks a backend-for-frontend first: it holds the tokens and gives the browser an HttpOnly, SameSite cookie. localStorage turns any XSS into full credential theft.
  • “When would you not use OAuth?” — a first-party app against your own API with no federation and no third-party clients. Nothing is being delegated, and a session cookie gives immediate revocation with none of the redirect, storage or rotation problems.