OpenID Connect and the ID Token
The identity layer on top of OAuth — what makes 'Sign in with Google' authentication rather than delegation.
OpenID Connect is a thin layer over OAuth 2.0 that adds the one thing OAuth deliberately left out: who the user is.
What OIDC adds
- The
openidscope, which switches the flow into OIDC mode. - An ID token — a JWT about the user, returned alongside the access token.
- A
/userinfoendpoint for additional profile claims. - A standard discovery document at
/.well-known/openid-configuration. - Standard claims:
sub,email,email_verified,name,picture.
The two tokens are not interchangeable
| ID token | Access token | |
|---|---|---|
| Answers | who is this user? | what may the bearer do? |
| Audience | your client id | the resource server |
| Read by | your app | the API |
| Format | always a JWT | anything, often opaque |
| Send to an API? | no | yes |
The aud claim is the crux. An ID token names your client id, so a token minted for another application cannot be replayed at your login endpoint. An access token has no such binding to you — which is exactly why using one as proof of identity is broken.
Verifying an ID token
- Fetch the provider's JWKS (cached) and verify the signature.
issequals the expected issuer, exactly.audequals your client id.expis in the future;iatis recent.noncematches the one you sent — this binds the token to this login attempt.- Only then read
sub, and use it as the account key.
Key the account on sub, not email
sub is stable and unique per provider. Emails change, and get reassigned inside companies. Store (provider, sub) as the identity, keep the email as a mutable attribute — and only auto-link accounts by email when email_verified is true, or you have built an account takeover.
Example
{
"iss": "https://accounts.google.com",
"aud": "abc-web.apps.googleusercontent.com",
"sub": "110169484474386276334",
"email": "[email protected]",
"email_verified": true,
"name": "Alice Anderson",
"picture": "https://lh3.googleusercontent.com/a/...",
"iat": 1754312400,
"exp": 1754316000,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1754312390
}When to use it
- An app keys accounts on (provider, sub) so a user who changes their email address keeps the same account.
- A security review rejects auto-linking by email address, which would let someone claim an existing account by registering an unverified matching email at another provider.
- An API rejects an ID token sent as a bearer credential because its audience is a client id rather than the API's identifier.
More examples
Verifying an ID token properly
Skipping the email_verified check is a well-known account takeover: register an unverified address matching a target's account at a lax provider, then 'link' into their data.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const GOOGLE_JWKS = createRemoteJWKSet(
new URL('https://www.googleapis.com/oauth2/v3/certs'));
export async function verifyIdToken(idToken, expectedNonce) {
const { payload } = await jwtVerify(idToken, GOOGLE_JWKS, {
algorithms: ['RS256'],
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID, // ← MUST be your client id
clockTolerance: 30,
maxTokenAge: '10m',
});
// Binds this token to the login attempt that started in THIS browser.
if (expectedNonce && payload.nonce !== expectedNonce) {
throw new Error('nonce_mismatch');
}
return payload;
}
export async function upsertFromIdToken(claims) {
// Identity key: (provider, sub). Never the email on its own.
const existing = await db.identities.find({ provider: 'google', sub: claims.sub });
if (existing) {
await db.users.update(existing.userId, { email: claims.email, name: claims.name });
return existing.userId;
}
// Linking to an existing local account by email is ONLY safe when the
// provider says the address is verified.
if (claims.email_verified) {
const byEmail = await db.users.findByEmail(claims.email);
if (byEmail) {
await db.identities.insert({ provider: 'google', sub: claims.sub, userId: byEmail.id });
return byEmail.id;
}
}
const user = await db.users.insert({
email: claims.email, name: claims.name,
emailVerified: !!claims.email_verified,
});
await db.identities.insert({ provider: 'google', sub: claims.sub, userId: user.id });
return user.id;
}The two tokens, used correctly
The last block is the classic 'sign in with' vulnerability: a malicious app collects an access token for its own scopes, then presents it to your login endpoint.
const { id_token, access_token } = await exchangeCode(code, verifier);
// ✅ ID token → verified on YOUR server, to establish who the user is
const claims = await verifyIdToken(id_token, tx.nonce);
const userId = await upsertFromIdToken(claims);
res.cookie('sid', await createSession(userId, req), COOKIE_OPTIONS);
// ✅ Access token → sent to the PROVIDER's API, to fetch their data
const profile = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
}).then((r) => r.json());
// ❌ Do not do either of these:
// Sending the ID token to an API as a credential — wrong audience entirely
fetch('https://dfg.com/api/orders', {
headers: { Authorization: `Bearer ${id_token}` },
});
// Trusting an access token as proof of identity — it names no client, so a
// token obtained by ANY app for this user would be accepted here.
app.post('/auth/google', async (req, res) => {
const profile = await fetchGoogleProfile(req.body.accessToken);
await loginAs(profile.email); // ← confused deputy: full takeover
});
Discussion