OAuth 2.0, OIDC and PKCE: signing in through an identity provider without leaking the code or the token Intermediate
Estimated reading time: 11 min read
Read first
The problem
Section titled “The problem”A shop has an SPA at shop.example, a mobile app, and a NestJS orders API at api.shop.example. Users sign in through an identity provider (IdP) at id.shop.example, so the shop never stores passwords itself.
The first version took shortcuts. The SPA posts the password to the IdP’s token endpoint and keeps the access token in localStorage. The mobile app logs in inside an embedded WebView. The API accepts any token the IdP signed.
Which flow should each client use, where should the tokens live, and what must the API check before trusting one?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| Situation | Choose | Condition |
|---|---|---|
| SPA with its own backend | BFF + Authorization Code + PKCE; browser holds only a session cookie | The BFF is a confidential client; cookie rules from Session or JWT |
| SPA with no backend at all | Browser-based public client, Authorization Code + PKCE | Least secure option in RFC 10017; short access tokens, refresh token rotation |
| Mobile app | Authorization Code + PKCE in the system browser | Claimed https redirect; refresh token in Keychain/Keystore |
| “Who is the user?” | OpenID Connect: openid scope, ID token |
The client reads the ID token; the API never accepts it |
| Implicit grant or password grant | Do not use | RFC 9700: implicit SHOULD NOT, password grant MUST NOT |
Core concepts
Section titled “Core concepts”OAuth 2.0 is about authorization: the client gets an access token to call an API on the user’s behalf. OpenID Connect (OIDC) adds authentication on top: an ID token that tells the client who logged in.
| ID token (OIDC) | Access token (OAuth) | |
|---|---|---|
Audience (aud) |
The client’s client_id |
The API, e.g. https://api.shop.example/orders |
| Who reads it | The client, once, at login | The API, on every request |
| Answers | “Who is this user, when did they log in?” | “May this request act for sub with this scope?” |
| Format | Always a signed JWT | JWT (RFC 9068, typ: at+jwt) or opaque, IdP’s choice |
The four OAuth roles in this scenario: the user (resource owner), the SPA or app (client), id.shop.example (authorization server), and the orders API (resource server).
A confidential client can keep a secret (a backend). A public client cannot: anything shipped in an SPA bundle or app binary can be extracted (RFC 6749 §2.1).
How it works
Section titled “How it works”Who holds which secret at each step, so that a stolen code or token is useless?
The BFF creates random state, nonce and a 43-character code_verifier, keeps them in a pre-login session, and hashes the verifier.
| Step | What changes | What it blocks |
|---|---|---|
| 0–1 | BFF stores state, nonce, verifier; browser carries the challenge |
Nothing secret in the browser |
| 2 | Password typed at the IdP; code bound to the challenge | App never sees the password |
| 3 | BFF compares state (and iss) with its session |
Login CSRF, IdP mix-up |
| 4 | IdP checks S256(code_verifier) == code_challenge |
A stolen code is worthless |
| 5 | BFF validates the ID token, issues __Host- cookie |
Replayed ID token (nonce), tokens in JS |
| 6–7 | API validates the access token | Forged or mis-targeted tokens |
| 8 | Refresh token rotated | Replayed refresh tokens |
PKCE: proof that the redeemer started the flow
Section titled “PKCE: proof that the redeemer started the flow”const verifier = randomBytes(32).toString('base64url'); // 43 chars, 256 bitsconst challenge = createHash('sha256').update(verifier).digest('base64url'); // method S256The verifier is 43–128 unreserved characters (RFC 7636). Only the challenge passes through the browser; the verifier goes straight to the token endpoint. A mismatch returns invalid_grant.
Always send code_challenge_method=S256. Omitted, it defaults to plain: challenge equals verifier, so intercepting one gives both.
RFC 9700 §2.1.1: public clients MUST use PKCE, confidential clients are RECOMMENDED to, and authorization servers MUST support it.
state, nonce and iss
Section titled “state, nonce and iss”state: one-time random value bound to the browser session (RFC 6749 §10.12). Without it, an attacker can feed you their own code and log you into their account (login CSRF).nonce: echoed in the ID token (OIDC Core). Optional in the code flow; it ties the ID token to this login.issin the callback (RFC 9207): blocks mix-up when one client uses several IdPs.
RFC 9700 lets a client rely on PKCE for CSRF protection only after confirming the IdP supports PKCE. Keeping state as well costs nothing.
Redirect URI: exact string match
Section titled “Redirect URI: exact string match”RFC 9700 §2.1: the IdP MUST compare redirect URIs by exact string, with one exception: the port of loopback URIs for native apps. Wildcards and prefix matching let an attacker steer the code to https://shop.example/callback/../open-redirect.
Why implicit and the password grant are gone
Section titled “Why implicit and the password grant are gone”| Grant | Problem | Status |
|---|---|---|
Implicit (response_type=token) |
Access token returned in the URL: leaks via history and referrer, can be injected; no PKCE, no sender binding | RFC 9700: SHOULD NOT use |
| Resource owner password (ROPC) | The client sees the password; no MFA or consent at the IdP; trains users to type passwords into any app | RFC 9700: MUST NOT use |
OAuth 2.1 removes both. It is still an Internet-Draft (draft -16, September 2026), not an RFC, but its rules already appear in RFC 9700.
Where tokens live
Section titled “Where tokens live”| Client | Pattern | Token exposure |
|---|---|---|
| SPA with backend | BFF: backend is the OAuth client, proxies API calls | None in the browser; cookie needs CSRF protection |
| SPA, backend only fetches tokens | Token-mediating backend | Access token in JS |
| SPA, no backend | Browser-based public client | Access and refresh tokens readable by any XSS |
| Mobile app | Public client, system browser (RFC 8252) | Refresh token in OS secure storage |
RFC 10017 lists these in decreasing order of security and strongly recommends the BFF for business apps and personal data.
RFC 8252 §8.12: native apps MUST NOT use embedded WebViews for authorization; the host app could read the password and cookies.
API-side validation
Section titled “API-side validation”The NestJS API receives a bearer token and must decide, without calling the IdP, whether to trust it.
Loading diagram…
// orders-api/src/auth/access-token.guard.ts — jose v6import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL(process.env.IDP_JWKS_URI!)); // jwks_uri from discovery
@Injectable()export class AccessTokenGuard implements CanActivate { async canActivate(ctx: ExecutionContext): Promise<boolean> { const req = ctx.switchToHttp().getRequest(); const [scheme, token] = (req.headers.authorization ?? '').split(' '); if (scheme !== 'Bearer' || !token) throw new UnauthorizedException(); try { const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://id.shop.example', audience: 'https://api.shop.example/orders', algorithms: ['ES256'], typ: 'at+jwt', clockTolerance: 30, }); req.user = { sub: payload.sub, scope: payload.scope }; return true; } catch { throw new UnauthorizedException(); } }}aud(RFC 9068 §4, RFC 9700 §2.3): the API rejects tokens not issued for it. This is what stops an ID token, or a token for another API, from working here.typ: at+jwtonly works if the IdP issues RFC 9068 tokens. Opaque tokens need introspection instead.- Key rotation:
createRemoteJWKSetrefetches on an unknownkid, at most once percooldownDuration(default 30 s); cache lifetime defaults to 10 minutes. - Validation ends at “who”; the BOLA check on the order is still yours.
Why each claim matters (alg: none, key confusion): Session or JWT.
Refresh tokens
Section titled “Refresh tokens”RFC 9700 §2.2.2: refresh tokens for public clients MUST be sender-constrained or rotated. Rotation and reuse detection are covered in Session or JWT. With a BFF, the refresh token never leaves the server.
Operational details
Section titled “Operational details”- Authorization codes: single-use, lifetime ≤ 10 minutes recommended (RFC 6749 §4.1.2); a reused code should revoke tokens already issued from it.
- OIDC requests MUST include the
openidscope; without it the response is plain OAuth. - Native redirect URIs: claimed
httpspreferred; desktop apps use a loopback IP with any port (RFC 8252 §7).
Real-world example
Section titled “Real-world example”A runnable in-memory simulation (IdP, token endpoint, orders API), node:crypto only:
npx --prefix examples ts-node examples/src/oauth2-oidc-pkce/pkce-flow-demo.ts1. S256(RFC 7636 App. B verifier) = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM matches spec: true3. Token endpoint stolen code, attacker has no verifier FAIL invalid_grant (code_verifier mismatch) replay the first (used) code FAIL invalid_grant (code already used) redirect_uri + "/../evil" FAIL redirect_uri not registered (exact match)4. Orders API access_token ACCEPT sub=user-42 scope=orders:read id_token sent as Bearer REJECT typ JWT is not an access token5. Key rotation (new kid 2026-11) 10 s after last JWKS fetch REJECT unknown kid 2026-11 61 s after: unknown kid -> refetch ACCEPT sub=user-42 scope=orders:readSection 5 is why an IdP publishes a new key before signing with it: a verifier inside its refetch cooldown rejects an unseen kid.
Trade-offs
Section titled “Trade-offs”What Authorization Code + PKCE + BFF gains
- Passwords only ever typed at the IdP; MFA and consent live there.
- A stolen code is useless without the verifier.
- No tokens in the browser; XSS cannot carry them away.
What it costs
- A BFF is a stateful server in the request path, with sessions and CSRF protection.
- More redirects and round trips than a password form.
- Key rotation and JWKS caching to operate.
Production checklist
Section titled “Production checklist”- Disable implicit and password grants for every client at the IdP.
- Register exact redirect URIs per client; separate
client_ids for SPA/BFF and mobile. - Require PKCE with
S256; rejectplain. - Generate a fresh
stateandnonceper login, bound to the pre-login session, single use. - SPA: put the tokens in a BFF; cookie
__Host-,Secure,HttpOnly,SameSite. - Mobile: system browser, claimed
httpsredirect, refresh token in Keychain/Keystore. - API: verify
alg,kidvia JWKS,iss,aud,exp,typ; reject ID tokens. - Enable refresh token rotation with reuse detection; keep access tokens to minutes.
Common mistakes
Section titled “Common mistakes”The most damaging API-side mistake is checking only the signature:
Accepting any token the IdP signed
Common
await jwtVerify(token, JWKS); // no issuer, audience, typPrefer
await jwtVerify(token, JWKS, { issuer, audience: ORDERS_API, algorithms: ['ES256'], typ: 'at+jwt' });Why
The IdP usually signs ID tokens and tokens for other APIs with the same keys. Without aud, any of them opens the orders API.
A second trap sits in the client, not the API:
| Misconception | Consequence | What to do |
|---|---|---|
| PKCE replaces the client secret | A BFF without client auth is a public client | Confidential clients use both |
state is optional with PKCE |
Lost CSRF protection if the IdP ignores PKCE | Keep state |
Self-check
Section titled “Self-check”- An attacker intercepts the code on a custom URL scheme. Which step fails for them, and why does
plainchange the answer? - Why does the API still reject an ID token signed by the same IdP key?
- The IdP adds a key and signs with it the same second. Why can some API instances briefly reject valid tokens, and which rotation order avoids it?
Summary
Section titled “Summary”References
Section titled “References”- RFC 6749: roles, client types, code lifetime, CSRF.
- RFC 7636: PKCE verifier format and
S256. - RFC 9700: the current OAuth security MUST/SHOULD list.
- OpenID Connect Core 1.0: ID token validation.
- RFC 10017: BFF and other browser architectures.
- RFC 8252: native apps, system browser, redirect URIs.
- RFC 9068: resource server checks for JWT access tokens.
- OAuth 2.1 draft: current status.
- jose:
jwtVerifyoptions, JWKS caching.