Skip to content
Knowledge Hub

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

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?

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

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).

Who holds which secret at each step, so that a stolen code or token is useless?

Authorization Code + PKCE through a BFF: the verifier and tokens never reach the browser
Authorization Code + PKCE through a BFF: the verifier and tokens never reach the browserStep 1/9: The BFF creates random state, nonce and a 43-character code_verifier, keeps them in a pre-login session, and hashes the verifier.timeverifier never leaves BFF …Click "Log in"state, nonce, verifierchallenge = S256(v)BrowserSPA, only a cookieApp backend (BFF)confidential clientIdentity providerauthorization serverOrders APINestJS resource server
Step 1/9

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 bits
const challenge = createHash('sha256').update(verifier).digest('base64url'); // method S256

The 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: 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.
  • iss in 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.

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.

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.

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 v6
import { 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+jwt only works if the IdP issues RFC 9068 tokens. Opaque tokens need introspection instead.
  • Key rotation: createRemoteJWKSet refetches on an unknown kid, at most once per cooldownDuration (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.

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.

  • 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 openid scope; without it the response is plain OAuth.
  • Native redirect URIs: claimed https preferred; desktop apps use a loopback IP with any port (RFC 8252 §7).

A runnable in-memory simulation (IdP, token endpoint, orders API), node:crypto only:

Terminal window
npx --prefix examples ts-node examples/src/oauth2-oidc-pkce/pkce-flow-demo.ts
1. S256(RFC 7636 App. B verifier) = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
matches spec: true
3. 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 token
5. 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:read

Section 5 is why an IdP publishes a new key before signing with it: a verifier inside its refetch cooldown rejects an unseen kid.

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.
  1. Disable implicit and password grants for every client at the IdP.
  2. Register exact redirect URIs per client; separate client_ids for SPA/BFF and mobile.
  3. Require PKCE with S256; reject plain.
  4. Generate a fresh state and nonce per login, bound to the pre-login session, single use.
  5. SPA: put the tokens in a BFF; cookie __Host-, Secure, HttpOnly, SameSite.
  6. Mobile: system browser, claimed https redirect, refresh token in Keychain/Keystore.
  7. API: verify alg, kid via JWKS, iss, aud, exp, typ; reject ID tokens.
  8. Enable refresh token rotation with reuse detection; keep access tokens to minutes.

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, typ

Prefer

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
  1. An attacker intercepts the code on a custom URL scheme. Which step fails for them, and why does plain change the answer?
  2. Why does the API still reject an ID token signed by the same IdP key?
  3. 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?