A signed token is not proof: verifying OpenID Connect relying-party flaws
OpenID Connect pushes the hard cryptography onto the identity provider and leaves your application one job: checking the ID token it gets back — and that is where sign-in breaks. A token can be perfectly signed and still be forged through algorithm confusion, minted for a different client, issued by the wrong provider in a mix-up, replayed from another session, or carry a mutable claim like email that the issuer never verified — the flaw behind nOAuth account takeovers. A practitioner’s guide to the checks OIDC Core actually requires of the relying party, and how to prove each gap is exploitable rather than flag it in review.
By The Xalgorix Team
The login worked. The user clicked “Sign in with” their provider, the browser bounced through an authorization server and came back with a token, and your application minted a session. The token is a real JWT, signed by the identity provider, and it decodes to the right name and email — so the person in front of you is who the token says they are. Except that nothing you just read proves that. OpenID Connect deliberately moves the hard cryptography to the identity provider and leaves the relying party — your application — with one job: checking the ID token it gets back. A signed token proves only that some provider issued a token. Whether it is the right token, for your application, from the provider you trust, bound to the session in front of you, and carrying a claim the issuer actually stands behind — that is five separate questions, and each one is a place real applications get taken over (CWE-287).
This is a relying-party piece. Every flaw below lives in the integration code, not in the provider, and every one of them has shipped in production software that passed its own tests. For each, the useful work is not spotting a missing check in review — it is proving the gap is exploitable: forging a token the server accepts, replaying one across sessions, or walking into an account that is not yours. That is the difference between “this looks wrong” and a finding an engineer has to fix.
aud, the replayed nonce, the attacker-set email — which is exactly the token an attacker sends.What the relying party is actually trusting
The ID token is a JWT: three base64url parts — header, payload, signature — joined by dots. The payload is a set of claims about the authentication event, and the signature is the provider's assertion that it produced them. Decoded, the parts that matter look like this:
// Header -- says which key and algorithm signed the token:
{ "alg": "RS256", "kid": "a1b2c3", "typ": "JWT" }
// Payload -- claims about the login:
{
"iss": "https://login.provider.com", // WHO issued it
"aud": "your-client-id", // WHO it is for
"sub": "248289761001", // stable, unique user id at this issuer
"email": "alice@corp.example", // a claim -- not an identity
"email_verified": true,
"nonce": "n-0S6_WzA2Mj", // binds the token to one login attempt
"iat": 1762500000,
"exp": 1762503600
}OpenID Connect does not leave what to do with this to taste. Section 3.1.3.7 of OIDC Core lists thirteen validation rules the client MUST apply, and the five flaws below are each what happens when one of them is skipped: the signature check (rules 6–8), the iss match (rule 2), the aud/azp check (rules 3–5), the exp/iat window (rules 9–10), and the nonce binding (rule 11). The spec even names the last one's purpose precisely: nonce is a value “used to associate a Client session with an ID Token, and to mitigate replay attacks.”
Proof #1: the signature you did not actually check
The first question is whether the token was signed by the provider at all. The most common relying-party bug is the bluntest: the integration decodes the token instead of verifying it. Almost every JWT library ships two calls — one that parses and one that parses and checks the signature — and PortSwigger documents how routinely developers reach for the wrong one. Once the signature is never checked, the payload is just attacker-authored JSON (CWE-347).
// The classic relying-party bug: decode, don't verify.
const claims = jwt.decode(idToken); // parses, checks NOTHING
req.session.user = claims.email; // trusts a string anyone can write
// So the token an attacker hands this code needs no real signature:
// header: {"alg":"none","typ":"JWT"}
// payload: {"sub":"attacker","email":"admin@corp.example"}
// encoded: base64url(header).base64url(payload). <- empty sig, dot staysEven code that does verify can be defeated if it lets the token choose the algorithm. Setting alg to none declares an unsecured JWT; servers usually reject these, but the rejection is often string-matching, so mixed capitalization (nOnE) or odd encodings slip past — and the trailing dot after the payload must stay. Worse is algorithm confusion: a server that verifies RS256 with the provider's public key, but picks its verification routine from the token's own alg header, can be handed an HS256 token signed with that same public key as the HMAC secret. The public key is not secret — providers publish it as a JWK at /.well-known/jwks.json — so the attacker signs a token of their choosing and the server validates it against the only key it has.
How you prove it, not just suspect it. This probe only means something where the relying party accepts an ID token directly— an implicit or hybrid flow whose response_type returns an id_token to the browser, or an API that takes one as a bearer. In a plain authorization-code flow the callback receives a code, not an ID token: the client fetches the token server-to-server over TLS, and OIDC Core rule 6 even lets it skip the signature check there, so there is no front-channel token to forge and a rejected callback proves nothing about signatures (it can fail for a missing code, or a state/nonce mismatch). Where a token is accepted directly: first send a valid one and confirm it mints a session — your control — then, against a test identity you own, submit one asserting a different sub with the signature stripped (alg: none) or forged by algorithm confusion. A session for the asserted subject is the proof; any other rejection is inconclusive until you have isolated the signature check from the state/nonceones. Keep the forged subject one you control, so the demonstration never touches a real user's account.
Proof #2: a valid token minted for someone else
A token can be genuinely signed by the right provider and still not be for you. OIDC Core rule 3 is explicit: the client MUST reject an ID token that does not list it as a valid audience, or that “contains additional audiences not trusted by the Client,” and rule 5 says that when an azp (authorized party) claim is present, the client should confirm it equals its own client_id. Skip the aud check and your application will accept any token the provider issued to any client — including a throwaway app the attacker registered against the same identity provider minutes earlier.
This is the quiet one, because the token is otherwise perfect: real issuer, valid signature, unexpired. Nothing about it looks forged, because nothing is. The only thing wrong is that it was addressed to a different client_id, and the relying party never read the envelope.
How you prove it. Register a second client with the same identity provider (or use one you already control), complete a normal login there to obtain a legitimately signed ID token whose aud is that second client, and replay it at the target application's callback. If the target accepts it and logs you in, it is not checking aud — and the blast radius is every application that trusts that provider. Keep both identities yours; you are proving the target accepts a token addressed elsewhere, which needs no victim.
Proof #3: the right token, from the wrong provider
When an application supports more than one identity provider, a new class opens: the mix-up attack, catalogued in Section 4.4 of RFC 9700, the OAuth 2.0 Security Best Current Practice (BCP 240, January 2025). The goal, in the RFC's words, is “to obtain an authorization code or an access token for an uncompromised authorization server.” If a client stores the user's chosen provider in a session and reuses one redirect URI for all of them, an attacker who runs their own provider can start a flow, silently redirect the browser to an honest provider, and let the victim's code come back to the client — which, believing the flow belonged to the attacker's provider, sends that code to the attacker's token endpoint.
RFC 9700 makes a defense against mix-up REQUIRED whenever a client talks to more than one provider, and the preferred one is issuer identification: the client MUST store, per request, the issuer it sent the user to, and compare it on return — “just storing the authorization server URL is not sufficient.” RFC 9207 supplies the mechanism: the authorization server MUST return an iss parameter naming itself, and “if the value does not match the expected issuer identifier, clients MUST reject the authorization response.” Where the server is known to support it, clients MUST reject responses that arrive without iss. Honest caveat: this is the hardest of the five to stage, because it needs a multi-provider app and an attacker-controlled provider — but where those preconditions hold, the proof is showing the client POST a victim-obtained code to an endpoint you control.
Proof #4: a token replayed because nothing bound it to this login
The nonce is what ties an ID token to the specific login that requested it. The client generates a fresh value, sends it in the authorization request, and the provider echoes it back inside the signed token; rule 11 of Section 3.1.3.7 requires the client to confirm the returned nonce matches the one it sent, and to guard against replay. Drop that check and a captured ID token — from logs, a referrer, a shared device, or an injected authorization code — can be replayed into a fresh session. RFC 9700 ties the same value to authorization-code injection in Section 4.5.3.2: the client MUST validate the nonce, and “unless and until that check succeeds, all tokens” must be disregarded.
How you prove it. Capture a complete, valid ID token from one authenticated session, start a brand-new login so the application issues a different nonce, and submit the captured token where the callback expects the fresh one. If it is accepted, the nonce binding is not enforced and the token is replayable. A correct implementation rejects it because the token's nonce no longer matches the session's — and burns the value after first use so even the matching token cannot be replayed twice.
Proof #5: trusting a claim the issuer never verified
The subtlest failure needs no forgery at all: the token is valid in every way above, and the relying party still hands over the wrong account — because it treats a mutable claim as an identity. This is the mechanism behind nOAuth, disclosed by Descope in June 2023. Microsoft Entra ID let any account holder set their account's email address to any value — including a domain they did not own and had never proven — and emit a perfectly valid token carrying it. Any application that used “Log in with Microsoft” and keyed accounts on the email claim could be taken over: the attacker sets their own account's email to the victim's, signs in, and the application matches them onto the victim's record. No password, no MFA, nothing the victim could detect.
The fix is to identify users by the pair the issuer actually stands behind — the immutable sub (subject) scoped to its iss (issuer) — and never by a mutable, cross-provider-collidable claim like email. The lesson outlived the specific bug: in June 2025, two years after disclosure, Semperis re-tested 104 SaaS applications and found nine — roughly nine percent — still vulnerable to the same cross-tenant abuse, having reported the findings to Microsoft in December 2024.
A relying-party check that holds
Collapse the five proofs into the validation the relying party owes every ID token. None of it is exotic; all of it is in OIDC Core 3.1.3.7 and RFC 9700, and each line is one of the gaps above closed:
verifyIdToken(token):
jws = parseCompactJWS(token) # exactly 3 parts; reject none/empty sig
assert jws.header.alg in {RS256} # ALLOWLIST; never pick the key from alg
assert verifySignature(jws, jwks[jws.header.kid])# provider's public key, selected by kid
assert jws.iss == EXPECTED_ISSUER # exact string match
assert CLIENT_ID in jws.aud # and reject untrusted extra audiences
if jws.azp: assert jws.azp == CLIENT_ID
assert now() < jws.exp # and iat within a sane window
assert jws.nonce == session.nonce; burn(session.nonce) # one login, one token
userId = (jws.iss, jws.sub) # identity = issuer + subject, NEVER emailTwo flow-level rules sit alongside it, both normative in RFC 9700: public clients MUST use PKCE to stop code injection, and a client talking to more than one provider MUST implement a mix-up defense. That defense is a separate, earlier step than the ID-token iss claim in the checklist above: before exchanging the code, compare the authorization response's issuer — the RFC 9207 iss parameter — against the issuer you stored for that request, and abort on mismatch. Validating the iss claim inside the returned token happens too late to stop a code from being sent to the wrong token endpoint. CSRF on the callback is covered by a one-time state bound to the user agent, unless PKCE or the nonce already provides it.
Where automated testing earns its keep, and where it does not
Four of these five proofs are mechanical, and that is exactly the kind of work worth automating: stripping a signature and flipping alg, forging an HS256 token from a published public key, replaying a token whose aud points at another client, and resubmitting a captured token against a fresh nonce are all deterministic probes with a binary, re-exploitable outcome — did a session come back for a token it should have refused? That is the kind of check Xalgorix is built to run and then re-exploit to prove, rather than infer from a missing line of code. The brand position — proof, not guesswork — is precisely the claim that a forged token minting a session is evidence, where “the code callsdecode()” is only a lead.
Be honest about the edges, though. The mix-up attack needs a second, attacker-controlled provider and an understanding of how the specific application tracks which issuer a flow belongs to — closer to modeling an application's state machine than to firing a payload. And nOAuth-style claim-trust is invisible to any check that only asks “is this token authentic?”: finding it means understanding how the application links and merges accounts, which claim it treats as identity, and whether the issuer ever verified that claim — reasoning about business logic that no signature test exercises. A tool can prove the first four gaps cold; the last two still reward a human who understands what the application is actually trusting. For OIDC, that is the whole game: a signed token is where verification starts, not where it ends.
Ready to see it prove a bug?
Start scanning on Cloud →