OAuth vs OIDC: The Difference in One Claim

There's a code pattern that appears in production systems everywhere, written by competent engineers, that reliably produces a complete authentication bypass:

There's a code pattern that appears in production systems everywhere, written by competent engineers, that reliably produces a complete authentication bypass:

POST /auth/mobile-login
{ "access_token": "ya29.a0AfB_..." }

→ server calls provider's userinfo endpoint with that token
→ gets back { "sub": "10769150350006150715113082367", "email": "[email protected]" }
→ server looks up the user by that sub
→ server issues its own session

The reasoning behind it is sound at every step. The token came from a real provider. The provider verified the user. The userinfo response is authoritative about who that user is. Therefore the person holding this token is Alice.

That last inference is false, and the gap it hides is the entire distinction between OAuth 2.0 and OpenID Connect.

The one-line version, and why it's not enough

The standard explanation is that OIDC is "OAuth plus an identity layer," and the identity layer is the ID token. True, and unhelpfully abstract. Here's the version that tells you what to do.

An access token answers: what may the bearer do? It is addressed to a resource server. It says nothing verifiable about who obtained it or which application it was given to. It is a capability — a key, not a statement.

An ID token answers: who authenticated, when, how, and to whom is this statement addressed? It is addressed to your application specifically, by client_id, and it is signed so you can verify that.

The critical asymmetry is in that last part. An access token does not tell you who it was issued to. An ID token does. Everything below follows from that one fact.

Why "call userinfo and trust the answer" is broken

Return to the code above. The vulnerability is that anyone with any valid access token for that provider can present it and be logged in as whoever that token belongs to.

Concretely: I write a small, legitimate-looking app — a photo tool, a fitness tracker, anything. Alice signs into my app with Google, granting the harmless scopes I asked for. I now hold an access token issued to my client, for Alice's account.

I take that token and POST it to your /auth/mobile-login. Your server calls Google's userinfo endpoint with it. Google answers, correctly and honestly: this token belongs to Alice, here's her sub and email. Your server issues Alice a session — to me.

Nothing was forged. No cryptography was broken. Google behaved correctly throughout; it answered the question it was asked, which was "whose token is this," not "was this token issued to the application now presenting it." The access token has no field naming its intended client, so no amount of care at the provider can help you.

This is a token substitution attack, sometimes called the confused deputy in this context, and it's not theoretical — it was the shape of a well-known flaw in early "Login with Facebook" mobile integrations and has recurred in many implementations since. It is the reason the OAuth 2.0 spec's security considerations contain the sentence that OAuth is not an authentication protocol, and the reason the OIDC working group existed at all.

The defence is not "validate the token more carefully." You can't — validation of an opaque access token means introspection, and introspection tells you the token is valid, which it is. The defence is to use a credential that carries its own intended recipient, and to check it.

What the ID token adds, claim by claim

An ID token is a JWT with a specific and non-negotiable validation procedure. The claims that matter, and the attack each one stops:

iss — the issuer. Check it against the exact expected value. Without this, a token from any issuer whose signature you happen to be able to verify is acceptable, which in a multi-IdP deployment means one tenant's IdP can mint identities for another's.

aud — the audience, which for an ID token is your client_id. This is the claim. It is what makes the token a statement addressed to you rather than a bearer capability. Verifying aud == my_client_id is what defeats the substitution attack above: the token I stole from my own photo app has aud of my client, not yours, and your check rejects it. If you validate one thing, validate this.

sub — the subject: a stable, opaque identifier for the user within this issuer. Note "within this issuer" — sub values are only unique per issuer, so your local user key must be (iss, sub), never sub alone. And never email, because email addresses change hands, get re-provisioned after an employee leaves, and are not verified by every provider. Keying accounts on email is the second-most-common serious mistake in this area, right behind the one at the top of this article.

nonce — a value your application generates, stores against the user's session, sends in the authorization request, and then requires to match in the returned ID token. Its job is replay: without it, an ID token captured from one flow can be injected into another, which is the ID-token-substitution counterpart of the access-token attack. In the authorization code flow with a confidential client, code exchange over the back channel already provides much of this protection — but nonce is what binds the token to this specific browser session you initiated, and it costs you one round-trip-worth of state. Use it.

There's a distinction here worth keeping straight, because these two parameters get conflated constantly:

  • state binds the authorization response to the browser session. It defends against CSRF on the redirect endpoint. It is checked before you do anything else.
  • nonce binds the ID token to the authentication request. It defends against token replay and injection.

They solve different problems at different layers and you need both.

exp, iat, auth_time — expiry, issuance, and when the user actually authenticated. The third is the one people ignore and then need: auth_time is what lets you enforce max_age, and it's what tells you whether "authenticated" means thirty seconds ago or from a session established eight days ago at the IdP. An ID token issued now can attest to an authentication that happened last week; if that matters to you — and for anything sensitive it does — auth_time is the only claim that tells you.

amr, acr — how they authenticated and to what assurance level. Password? Password plus TOTP? A hardware key? The IdP knows; without these claims you don't, and "the user is authenticated" flattens a phishing-resistant login and a shared password into the same boolean.

at_hash — a hash of the access token, present when both are issued together. It binds the two, so you can verify that the access token you received belongs with this ID token rather than having been swapped in.

That last claim is the neat summary of the whole design: OIDC's contribution is largely about binding — binding the identity assertion to a client, to a session, to a moment in time, to a specific access token. OAuth on its own provides authorization with no bindings, because for a capability, bindings are precisely what you don't want.

The signature is the boundary, and where you check it matters

An ID token's trustworthiness comes entirely from its signature, verified against the issuer's published JWKS. Which means one rule dominates: never accept an ID token from a channel that hasn't been validated, and never skip validation because the token "came from the client."

Two specific failures:

alg: none. JWT permits an unsecured mode. Several libraries once accepted it by default, and this remains the most embarrassing vulnerability class in the ecosystem. Pin your expected algorithms explicitly — an allowlist, checked before verification, not read from the token's own header.

Confusing "I received it over TLS from my own frontend" with "it's verified." A mobile app or SPA sending an ID token to your backend is sending you a string a user controls. It gets the full validation treatment: signature, iss, aud, exp, nonce. The fact that your own app sent it means nothing; your app runs on a device you don't control.

The /userinfo endpoint's actual purpose

Given the above, why does /userinfo exist at all?

It's for fresh or additional claims, not for establishing who authenticated. The ID token is a point-in-time snapshot, deliberately small — you don't want a 6KB ID token, and you don't want profile changes to require re-authentication. /userinfo lets you fetch the current profile, and it lets you request scopes whose claims are too large or too sensitive to embed.

The rule: the ID token establishes identity; userinfo enriches it. Call userinfo if you like, but the sub you trust is the one from the validated ID token, and if userinfo returns a different sub, the correct response is to reject the whole exchange as an attack — which is, in fact, exactly what the OIDC spec requires.

When OAuth alone is the right answer

None of this argues for OIDC everywhere. Plain OAuth 2.0 is correct — better, even — for a large class of problems, and reaching for OIDC there adds a user concept you don't need.

Machine-to-machine. Client credentials grant. There is no user, no authentication event, nothing for an ID token to describe. An access token scoped to what the service may do is the whole requirement.

Third-party API access where you don't need identity. A tool that posts to a user's calendar needs authorization to write to that calendar. If it has no accounts of its own, it doesn't need to know who the user is, and asking for identity claims it doesn't use is a needless privacy expansion. Ask for the scope, use the token, don't request openid.

Delegated access between services with token exchange. Narrowing an audience, chaining on-behalf-of calls — this is authorization machinery, and access tokens with proper aud and act claims are the right tool.

The dividing line is genuinely simple: do you need to create or resume a local session for a human? If yes, you need an authentication protocol, and that's OIDC. If you need to call an API, you need authorization, and that's OAuth. A great many systems need both in the same flow, which is why they arrive together and why people conflate them.

The practical checklist

If you're implementing an OIDC relying party, the validations that must exist, in order:

  1. state matches the value stored for this browser session. Reject before anything else.
  2. Exchange the code at the token endpoint over the back channel — with PKCE, for every client type, public or confidential.
  3. Verify the ID token signature against the issuer's JWKS, with an explicit algorithm allowlist.
  4. iss equals the exact expected issuer.
  5. aud contains your client_id, and if azp is present it equals your client_id. This is the check that stops token substitution.
  6. nonce matches what you stored.
  7. exp / iat are sane, with small clock skew tolerance (a minute or two, not an hour).
  8. auth_time satisfies your freshness requirement, if you have one.
  9. Key the local user on (iss, sub). Never on email.
  10. If you also got an access token, verify at_hash.

And the one to never do: do not accept an access token as proof of identity. Not from your mobile app, not from your SPA, not from a partner. If a client sends you an access token and asks you to log someone in, the answer is that the flow is wrong — you want an ID token, or you want the client to complete a code flow against you directly.

The point

The name "OpenID Connect" makes it sound like a competing protocol. It isn't; it's a profile — a set of constraints and one additional token type layered on OAuth 2.0. And the substance of the layer, stripped down, is a signed statement that says who authenticated, when, how, and — the part that does the real work — to whom this statement is addressed.

An access token is a key. Keys don't have names on them, which is why finding one on the street doesn't tell you whose house it opens, and why holding one is not evidence of who you are. An ID token is a signed letter with your name in the address field.

The difference between a system that authenticates correctly and one that can be logged into by anyone with a Google account is, quite literally, whether you check that field.