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 openid scope, which switches the flow into OIDC mode.
  • An ID token — a JWT about the user, returned alongside the access token.
  • A /userinfo endpoint 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 tokenAccess token
Answerswho is this user?what may the bearer do?
Audienceyour client idthe resource server
Read byyour appthe API
Formatalways a JWTanything, often opaque
Send to an API?noyes

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

  1. Fetch the provider's JWKS (cached) and verify the signature.
  2. iss equals the expected issuer, exactly.
  3. aud equals your client id.
  4. exp is in the future; iat is recent.
  5. nonce matches the one you sent — this binds the token to this login attempt.
  6. 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

Example · json
{
  "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.

Example · javascript
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.

Example · javascript
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

  • Be the first to comment on this lesson.