Access Token vs ID Token: Never Treat Them as Interchangeable
Learn the difference between access tokens and ID tokens, which audience each must target, how to validate both, and how token substitution breaks APIs.
An ID token tells an application that an identity provider authenticated a user for that application. An access token authorizes calls to a protected resource. Send the ID token to your API, and you are asking the API to accept a token that was issued to somebody else: the client application.
Both tokens may be signed JWTs. They may contain the same sub, come from the same issuer, and use the same signing key. None of that makes them interchangeable. The intended audience, validation rules, and consumer are different.
The difference in one table
| Property | ID token | Access token |
|---|---|---|
| Protocol | OpenID Connect | OAuth 2.0 |
| Answers | Who authenticated, and how? | What may call this resource? |
| Intended consumer | OAuth/OIDC client application | Resource server or API |
| Typical audience | Client's client_id | API's canonical resource identifier |
| Typical contents | sub, auth_time, nonce, requested identity claims | scope, client_id, resource audience, authorization claims |
| May be opaque? | No; OIDC defines it as a JWT | Yes; OAuth does not require a JWT format |
| Sent to an API as bearer credential | No | Yes |
| Used directly as an application session | No; validate it, then establish a session | No; it authorizes resource access during its lifetime |
The OpenID Connect Core specification defines an ID token as claims about an authentication event. RFC 9068 defines a JWT profile for access tokens and requires resource servers to validate the access-token type, issuer, signature, expiry, and their own audience.
An ID token is addressed to the client
Suppose a user signs in to acme-web. A simplified ID token might contain:
{
"iss": "https://tenant.auth.example",
"sub": "user_123",
"aud": "acme-web",
"exp": 1787500800,
"iat": 1787499900,
"auth_time": 1787499890,
"nonce": "n-0S6_WzA2Mj"
}The audience is acme-web, the OIDC client that requested authentication. The client validates the token and can use the issuer-plus-subject pair as the authenticated identity when it creates its own session.
The token does not say that payments-api should accept it. It does not necessarily contain API scopes. It was not minted as a bearer credential for that resource. A valid signature only proves that the issuer signed those claims; it does not rewrite the audience.
The client must validate at least:
- The signature using keys from the expected issuer
- Exact
issmatch - Its own
client_idinaud exp,iat, and other applicable time claims- The transaction
noncewhen one was sent - The allowed signing algorithm
If any check fails, the authentication fails. Decoding the JWT payload is not validation.
An access token is addressed to a resource
Now the same client requests access to https://api.acme.example. A JWT access token might contain:
{
"iss": "https://tenant.auth.example",
"sub": "user_123",
"aud": "https://api.acme.example",
"client_id": "acme-web",
"scope": "invoices:read invoices:pay",
"exp": 1787500800,
"iat": 1787499900,
"jti": "token_7c1f"
}The API is the audience. client_id identifies the application acting in the grant, while sub identifies the user in a user-delegated flow. The scopes describe the granted authorization, but the API must still apply its own business rules.
Only the resource server should depend on the access token's structure. RFC 9068 warns clients not to inspect access-token claims because an authorization server can change from JWT to opaque tokens. The client should carry the token to the named resource, not build UI authorization from its decoded payload.
The substitution attack passes signature checks
The dangerous implementation looks like this:
// Wrong: signature + expiry do not establish token purpose.
const claims = await verifyJwt(anyIncomingToken);
return loadUser(claims.sub);An attacker signs in to a client they control or obtains an ID token legitimately. They send that ID token to an API whose verifier accepts any JWT signed by the shared issuer. Signature and expiry pass. The sub names a real user. If the API skips audience and token-purpose checks, the wrong token crosses the boundary.
This is a cross-JWT confusion problem. RFC 8725, the JWT Best Current Practices, recommends mutually exclusive validation rules for different JWT kinds. RFC 9068 goes further for its access-token profile: an access token uses typ: at+jwt, and a resource server rejects another type.
Different signing keys can add defence in depth, but they are not a substitute for purpose and audience validation. Systems often publish several valid keys in one JWKS set, and key rotation makes key identity a poor expression of token intent.
Validate the ID token in the client
With jose, a server-side OIDC client can validate the ID token like this:
import { createRemoteJWKSet, jwtVerify } from "jose";
const issuer = "https://tenant.auth.example";
const clientId = "acme-web";
const jwks = createRemoteJWKSet(new URL(`${issuer}/v1/oauth/jwks.json`));
export async function validateIdToken(idToken: string, expectedNonce: string) {
const { payload } = await jwtVerify(idToken, jwks, {
issuer,
audience: clientId,
algorithms: ["RS256"],
});
if (payload.nonce !== expectedNonce) {
throw new Error("invalid ID-token nonce");
}
if (typeof payload.sub !== "string") {
throw new Error("missing ID-token subject");
}
return { issuer, subject: payload.sub };
}Use a mature OIDC library where possible; it will also handle discovery, key rotation, multi-audience rules, clock skew, and flow-specific checks. The important detail here is audience: clientId. The ID token belongs at the client.
After validation, create an application session. Do not use the raw ID token as a general-purpose cookie and do not forward it to downstream APIs.
Validate the access token in the API
The resource server applies a separate policy:
import { createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from "jose";
const issuer = "https://tenant.auth.example";
const apiAudience = "https://api.acme.example";
const jwks = createRemoteJWKSet(new URL(`${issuer}/v1/oauth/jwks.json`));
export async function validateAccessToken(accessToken: string) {
const header = decodeProtectedHeader(accessToken);
if (header.typ !== "at+jwt") throw new Error("wrong token type");
const { payload } = await jwtVerify(accessToken, jwks, {
issuer,
audience: apiAudience,
algorithms: ["RS256"],
});
const scopes = new Set(String(payload.scope ?? "").split(" "));
if (!scopes.has("invoices:read")) throw new Error("insufficient scope");
return payload;
}This example follows the RFC 9068 JWT access-token profile. If your provider issues opaque access tokens, use its introspection or validation endpoint instead. If it uses a provider-specific JWT profile, enforce that profile's explicit token-purpose marker. Do not loosen the check to accommodate both token kinds in one function.
An API validation checklist is:
- Require the expected bearer-token transport.
- Enforce the access-token type or provider-specific purpose marker.
- Pin acceptable algorithms.
- Verify the signature with trusted issuer keys.
- Match the exact issuer.
- Require the API's own resource identifier in
aud. - Enforce expiry and applicable not-before time.
- Check required scopes and contextual authorization.
The signature is step four, not the entire checklist.
Audience examples make the boundary obvious
Assume one user signs in through a web client and calls two APIs:
| Token | aud | Valid consumer | Rejected by |
|---|---|---|---|
| ID token | acme-web | Acme web client | Payments API, reports API |
| Access token A | https://payments.acme.example | Payments API | Acme web client as ID proof, reports API |
| Access token B | https://reports.acme.example | Reports API | Acme web client as ID proof, payments API |
If both APIs accept any issuer-signed token, audience separation is decorative. If each API requires itself in aud, a token stolen from reports cannot be replayed at payments. The same principle is why audience validation matters for MCP servers.
UserInfo needs an access token
OpenID Connect's UserInfo endpoint is a protected resource. The client calls it with an access token, and the endpoint returns claims allowed by the token's scopes.
That produces another useful rule:
- Read authentication claims already present in a validated ID token at the client.
- Use the access token to call UserInfo or another API.
- Never send the ID token to UserInfo.
- Confirm the UserInfo
submatches the validated ID token'ssubbefore combining claims.
An access token may happen to contain a name or email, but the client should not treat that JWT payload as a stable profile contract. Ask the intended resource through its documented interface.
NamoID keeps the purposes distinct
For OIDC applications, NamoID issues an ID token only when the openid scope is granted. Its aud is the application's client_id; it carries authentication context such as auth_time, the transaction nonce, and identity claims allowed by scopes.
NamoID access tokens carry an explicit token_use: "access", the client identifier, scopes, and a unique token ID. When an OAuth flow supplies an RFC 8707 resource, the access token's aud is that resource. A resource server should always request and validate its own audience instead of accepting an audience-less general token.
NamoID's UserInfo endpoint verifies the signature and issuer, then rejects any token whose token_use is not access. A correctly signed NamoID ID token therefore cannot be substituted at UserInfo.
This is also why the hosted-auth SDK validates the returned access token before handing it to the application callback. The application still owns its local session and must not confuse that session with either raw token.
Common mistakes
Using an access token to decide who signed in
The OAuth client should use a validated ID token or UserInfo response for OIDC identity. An access token is intended for a resource and may be opaque.
Sending the ID token in Authorization: Bearer
Bearer transport does not change token purpose. The API must reject it because its type and audience are wrong.
Checking sub but not iss
Subject identifiers are scoped to an issuer. Store and compare the pair (iss, sub), not a globally assumed sub.
Checking the signature but not aud
A valid issuer signs tokens for many clients and resources. Audience answers whether this particular receiver is one of them.
Authorizing only from ID-token roles
ID-token claims help the client understand authentication. The API should enforce authorization from its access-token scopes or current server-side policy, not trust a UI-facing identity assertion as an API permission grant.
Keep two validators
The safest implementation is boring: one ID-token validator in the OIDC client and one access-token validator in each resource server. They can share JWKS caching, but not acceptance rules.
Validate ID tokens for the client audience and authentication transaction. Validate access tokens for the API audience, token purpose, and scopes. If one function accepts both, it is not convenient abstraction; it is a substitution vulnerability waiting for the right signed JWT.
Review the full JWT validation order, then use RFC 9068 and OpenID Connect Core as the acceptance contract for each side.