OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
"Login with Google/Microsoft/Okta works, so auth is done." I hear that in almost every SPA security review I run. Then we open DevTools, copy the token the Vue or React app sends to the API, decode it, and find the API i
"Login with Google/Microsoft/Okta works, so auth is done." I hear that in almost every SPA security review I run. Then we open DevTools, copy the token the Vue or React app sends to the API, decode it, and find the API is accepting the ID token as a bearer token, never checks aud, and the frontend never verifies the nonce it generated. The login flow works fine. The authorization model has three separate holes in it.
While building the token inspection tools on PixelValut Tools, I have audited OAuth and OpenID Connect integrations in Laravel, Node.js, Nuxt and .NET codebases. The bugs are rarely in the identity provider. They are in what the client and the API assume about the tokens they receive. This post is the forensic routine I use: capture the redirect, decode the tokens, verify each claim against what it should be, and find where trust is misplaced.
📚 Series Roadmap — Browser-Side Security & Zero-Trust Forensics
- Part 1: Deobfuscating Malicious npm & Browser Payloads: Base64, Hex, Unicode Escapes & Homoglyphs
- Part 2: Webhook Security in Production: HMAC-SHA256 Signatures, Timestamp Drift & Replay Protection
- Part 3 (This Post): OAuth 2.1, OIDC ID Tokens & PKCE Forensics: Auditing Claims, Nonces & Audience Confusion in SPAs
- Part 4: Regular Expression Denial of Service (ReDoS) in JS & PHP: Spotting Catastrophic Backtracking
- Part 5: Subdomain Takeover & Dangling DNS Reconnaissance: Auditing CNAME, NS, TXT & TLS Certificates
- Part 6: Zero-Trust Reverse Proxy Hardening in Nginx: Rate Limiting, Request Smuggling Guards & mTLS
How do you validate an OIDC ID token and audit PKCE in a single-page app?
Direct answer: Verify the ID token signature against the provider's JWKS using an allowlisted algorithm, then check that iss exactly matches the issuer, aud contains your client ID, exp and iat are within tolerance, and nonce equals the value you stored before redirecting. Use PKCE with S256, check state, and send only access tokens to APIs.
Step 1: Capture the flow before you read any code
I start with the network tab, not the source. Log in once with "Preserve log" on and collect three requests:
1. GET https://idp.example.com/authorize?response_type=code&client_id=spa-web
&redirect_uri=https://app.example.com/callback&scope=openid profile email api.read
&state=Xk3...&nonce=9fQ...&code_challenge=E9Mel...&code_challenge_method=S256
2. GET https://app.example.com/callback?code=SplxlOB...&state=Xk3...&iss=https://idp.example.com
3. POST https://idp.example.com/oauth/token
grant_type=authorization_code&code=SplxlOB...&code_verifier=dBjftJeZ4CVP...
&redirect_uri=https://app.example.com/callback&client_id=spa-web
Paste URL 1 into the PixelValut URL Parser and read each query parameter separately. Things I flag on sight:
-
response_type=tokenorid_token token: the implicit flow. OAuth 2.1 drops it. Tokens land in the URL fragment, browser history and sometimesRefererheaders. - No
code_challenge, orcode_challenge_method=plain: PKCE missing or weakened. OAuth 2.1 requires PKCE for every authorization code flow. - No
state: no CSRF protection on the callback. - No
noncewhenopenidis in scope: ID token replay is not bound to this login attempt. -
redirect_uriwith wildcards or an open path the provider accepts loosely, likehttps://app.example.com/*. OAuth 2.1 requires exact string matching.
Step 2: Decode both tokens and compare them side by side
From the token response, take the id_token and the access_token and decode them in the JWT Decoder. Decoding is local Base64URL parsing, not verification, and you should only paste tokens from test accounts. I explained the safe workflow in How to Decode a JWT Safely.
A typical pair looks like this:
// ID token payload (for the SPA)
{
"iss": "https://idp.example.com",
"sub": "248289761001",
"aud": "spa-web",
"exp": 1793473200, "iat": 1793469600,
"nonce": "9fQ...",
"email": "[email protected]"
}
// Access token payload (for the API)
{
"iss": "https://idp.example.com",
"sub": "248289761001",
"aud": "https://api.example.com",
"client_id": "spa-web",
"scope": "api.read",
"exp": 1793470500
}
The aud values tell you who each token is for. The ID token is addressed to the client (spa-web). It proves who logged in, to the app. The access token is addressed to the API. If the SPA sends the ID token in Authorization: Bearer, and the API accepts it, the API is trusting a token that was never meant for it.
| Check | ID Token (OIDC) | Access Token (OAuth 2.x) | Who Must Validate | Failure I Find Most Often |
|---|---|---|---|---|
Intended audience (aud) |
Your client ID | Your API identifier | Client for ID token, API for access token | API skips aud, accepts tokens minted for other apps on the same IdP |
nonce |
Required when sent in request | Not present | Client | SPA generates nonce but never compares it |
Signature & alg |
JWKS, provider's algorithm | JWKS (if JWT), or introspection if opaque | Both | Library accepts any alg in header, including none or HS256 with public key |
Lifetime (exp, iat) |
Short, checked once at login | Short, checked every request | Both | Clock skew set to hours "to fix a bug" |
| Scopes / permissions | Not for authorization |
scope, roles, permissions
|
API | API authorizes on email domain from ID token |
| Storage in SPA | Memory, then discard | Memory or BFF cookie | Client |
localStorage with long-lived refresh tokens |
Step 3: Audience confusion, the bug that keeps shipping
Here is a Node.js API middleware I found in a real review (names changed):
// BEFORE: verifies the signature, nothing else that matters
import jwt from 'jsonwebtoken';
export function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, PUBLIC_KEY); // no audience, no issuer, no algorithms
req.user = decoded;
next();
}
Every token signed by that identity provider passes: tokens for the marketing site, the internal admin tool, a partner's app registered in the same tenant, and the SPA's ID token. On multi-tenant providers, a token from another tenant can pass too if the issuer is not pinned.
// AFTER: pin issuer, audience and algorithm; fetch keys from JWKS by kid
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'));
export async function auth(req, res, next) {
try {
const token = req.headers.authorization?.replace(/^Bearer /, '');
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://idp.example.com',
audience: 'https://api.example.com',
algorithms: ['RS256'],
clockTolerance: 30,
});
if (!String(payload.scope ?? '').split(' ').includes('api.read')) {
return res.status(403).json({ error: 'insufficient_scope' });
}
req.user = { sub: payload.sub, scope: payload.scope };
next();
} catch {
res.status(401).json({ error: 'invalid_token' });
}
}
The Laravel equivalent with firebase/php-jwt is just as short: load the key set with JWK::parseKeySet(), decode with JWT::decode($token, $keys), then explicitly compare $claims->iss and $claims->aud and reject on mismatch. That library verifies signature and expiry, but issuer and audience checks are your job.
To confirm a token really was signed by the key you think it was, paste the token and the provider's public key into the JWT Signature Verifier. If you are still on HS256 shared secrets anywhere, re-read my earlier post on JWT security pitfalls like alg: none and weak HMAC secrets.
Step 4: Verify PKCE and nonce on the client
PKCE stops a stolen authorization code from being redeemed by someone who does not hold the code_verifier. It only works if the challenge is derived correctly:
// Browser: generate verifier, S256 challenge, state and nonce
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); // 43 chars
const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
const state = b64url(crypto.getRandomValues(new Uint8Array(16)));
const nonce = b64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem('oidc_tx', JSON.stringify({ verifier, state, nonce }));
Then on the callback: compare state from the URL with the stored value before exchanging the code, and after the exchange compare the ID token's nonce with the stored nonce. If the authorization response includes an iss parameter (RFC 9207), compare it with the issuer you started with. That blocks mix-up attacks when an app supports more than one provider. Delete the transaction record after use so it cannot be replayed.
A quick forensic test: recompute BASE64URL(SHA256(code_verifier)) from request 3 and compare it with code_challenge from request 1. If they do not match and login still succeeds, the provider is not enforcing PKCE for that client. That is a configuration issue to raise immediately.
Step 5: Where the SPA keeps tokens
For anything handling sensitive data, the current IETF guidance for browser-based apps leans toward a Backend-for-Frontend: a small server component (a Laravel or Nuxt server route works well) does the code exchange, keeps tokens server-side, and gives the browser an HttpOnly; Secure; SameSite=Lax session cookie. If you must keep tokens in the browser, keep access tokens in memory, use short lifetimes, and make sure the provider rotates refresh tokens on every use so a stolen one is detected. Lock down origins too. A loose CORS policy undoes a lot of this, which I covered in CORS Misconfigurations in Production APIs.
The 9-point OAuth/OIDC forensic checklist
-
Flow type:
response_type=codeonly. No implicit, no password grant. -
PKCE:
code_challenge_method=S256, and the verifier recomputes to the challenge. - State: present, random, checked before the code exchange.
-
Nonce: present with
openid, checked against the ID token. - Redirect URIs: exact matches registered at the provider, no wildcards.
-
API audience: API rejects tokens whose
audis not its own identifier, including ID tokens. -
Issuer and algorithm: issuer pinned, algorithms allowlisted, keys from JWKS by
kid. -
Authorization: decisions based on
scope/roles in the access token, never on ID token profile claims. -
Storage and lifetime: BFF cookie or in-memory tokens, short
exp, rotating refresh tokens, clock tolerance under a minute.
Frequently asked questions
Can my API accept the ID token if it is signed by the same provider?
It should not. The ID token's audience is the client, and it carries no scopes for your API. Accepting it lets any app that can obtain a user's ID token call your API as that user. Request an access token with the API as audience instead.
Is PKCE enough to replace the state parameter?
PKCE protects the code exchange, and OAuth 2.1 notes it also provides CSRF protection when the server enforces it. I still use state because it is cheap, it helps when debugging, and not every provider enforces PKCE correctly for every client.
What clock skew tolerance is reasonable for exp and iat?
Thirty to sixty seconds. If you need more, fix NTP on the server. Long tolerances quietly extend the life of every stolen token.
Is it safe to paste tokens into an online JWT decoder?
Only test tokens, and only into a tool that decodes in the browser without sending the token anywhere. A production access token is a live credential until it expires.
Why does aud sometimes appear as an array?
The spec allows aud to be a string or an array. If it is an array with more than one value, OIDC says the client should check the azp claim equals its client ID. Your validation library should handle both forms, so test it with each.
How do I detect a token issued to a different tenant?
Compare the iss (and tenant ID claim, if your provider uses one) with the exact issuer your app is configured for. Never accept a "common" or multi-tenant issuer pattern without also allowlisting the tenants you serve.
Next in this series, Part 4 covers ReDoS and catastrophic regex backtracking in JavaScript and PHP.
Follow & Connect
- Website: PixelValut Tools
- YouTube: PixelValut (@pixel-valut)
- TikTok: PixelValut (@pixelvalut007)
- GitHub: Muhammad Ali Mughal (@ali111887)
- LinkedIn: Muhammad Ali Mughal
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.