Loading content...
OAuth 2.0 solved the delegation problem brilliantly—allowing third-party applications to access resources without sharing passwords. But it left a critical gap: OAuth 2.0 is an authorization framework, not an authentication protocol. It can tell you what someone is allowed to do, but not who that someone actually is.
This distinction might seem academic, but it's profound. Applications using OAuth 2.0 alone for login were essentially saying "someone with a valid token wants in"—without standardized verification of who that someone was. Different providers implemented user identity differently, creating fragmentation and security gaps.
OpenID Connect (OIDC) emerged as the solution—an identity layer built on top of OAuth 2.0. It adds standardized authentication, user identity claims, and a consistent way to verify "this is who they say they are." Today, OIDC powers login experiences across Google, Microsoft, Apple, Okta, Auth0, and virtually every modern identity provider.
By the end of this page, you will understand the critical difference between OAuth 2.0 and OIDC, master ID token structure and claims, implement OIDC authentication flows correctly, and leverage OIDC's standardized endpoints for robust identity verification.
Understanding the relationship between OAuth 2.0 and OpenID Connect is fundamental. They're not competitors—OIDC is built on OAuth 2.0, extending it with identity capabilities.
OAuth 2.0 Alone:
OpenID Connect (OAuth 2.0 + Identity Layer):
| Aspect | OAuth 2.0 | OpenID Connect |
|---|---|---|
| Primary Purpose | Delegated authorization | Authentication + Authorization |
| Core Question | What can this client access? | Who is this user? |
| Key Token | Access Token | ID Token + Access Token |
| User Info | Non-standard, varies by provider | Standardized claims (sub, email, name, etc.) |
| Discovery | No standard mechanism | /.well-known/openid-configuration |
| Scopes | Provider-defined | Standard: openid, profile, email, phone, address |
| Token Format | Often opaque or JWT | ID Token must be JWT |
| Client Auth | Optional | Often required for confidential clients |
Using pure OAuth 2.0 for login (without OIDC) is a common mistake. Getting an access token proves the user authorized something, but not who authorized it. An attacker could potentially inject a token obtained elsewhere. OIDC's ID token, with proper validation, provides cryptographic proof of identity. Always use OIDC for authentication, not bare OAuth 2.0.
The Simple Distinction:
Think of a hotel key card:
Both are needed for a complete identity and access management solution.
The ID Token is OIDC's key contribution—a JWT that conveys identity claims about the authenticated user. Unlike access tokens (which may be opaque), ID tokens are always JWTs with a standardized structure.
ID Token Structure:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imtex123"}.
eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJzdWIiOiIxMT
AwMjIzMzQ0NTU2Njc3ODgiLCJhdWQiOiJteWFwcC5jbGllbnRpZCIsImV4cCI
6MTcwNDEyMzQ1NiwiaWF0IjoxNzA0MTE5ODU2LCJub25jZSI6ImFiY2RlZjE
yMzQ1NiIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImVtYWlsX3Zlcmlma
WVkIjp0cnVlLCJuYW1lIjoiSm9obiBEb2UifQ.
[signature]
The three parts (header, payload, signature) are base64url-encoded and separated by dots.
| Claim | Name | Description | Example |
|---|---|---|---|
| iss | Issuer | URL of the token issuer (IdP) | https://accounts.google.com |
| sub | Subject | Unique identifier for the user (stable, opaque) | 110022334455667788 |
| aud | Audience | Client ID(s) this token is intended for | myapp.clientid |
| exp | Expiration Time | Unix timestamp when token expires | 1704123456 |
| iat | Issued At | Unix timestamp when token was issued | 1704119856 |
| Claim | Scope Required | Description |
|---|---|---|
| nonce | openid | Ties token to specific auth request (replay protection) |
| auth_time | openid | When user last actively authenticated |
| acr | openid | Authentication Context Class Reference (security level) |
| User's email address | ||
| email_verified | Whether email has been verified (boolean) | |
| name | profile | User's full name |
| given_name | profile | First/given name |
| family_name | profile | Last/family name |
| picture | profile | URL to user's profile picture |
| phone_number | phone | User's phone number |
| address | address | User's physical address (JSON object) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Decoded ID Token Payload Exampleinterface IDTokenPayload { // Required claims iss: string; // "https://accounts.google.com" sub: string; // "110022334455667788" - stable user identifier aud: string | string[]; // "myapp.clientid" or ["client1", "client2"] exp: number; // 1704123456 - expiration timestamp iat: number; // 1704119856 - issued at timestamp // Recommended for security nonce?: string; // "abc123" - ties request to response auth_time?: number; // 1704119800 - when user actually authenticated // Standard claims from scopes email?: string; // "user@example.com" email_verified?: boolean; // true name?: string; // "John Doe" given_name?: string; // "John" family_name?: string; // "Doe" picture?: string; // "https://example.com/photo.jpg" // Additional context acr?: string; // "urn:mace:incommon:iap:silver" - auth assurance level amr?: string[]; // ["pwd", "mfa"] - auth methods used azp?: string; // Authorized party - client that auth'd} // Example decoded tokenconst exampleToken: IDTokenPayload = { iss: "https://accounts.google.com", sub: "110022334455667788", aud: "123456789.apps.googleusercontent.com", exp: 1704123456, iat: 1704119856, nonce: "n-0S6_WzA2Mj", email: "user@example.com", email_verified: true, name: "Jane Smith", given_name: "Jane", family_name: "Smith", picture: "https://lh3.googleusercontent.com/a/photo.jpg", auth_time: 1704119800, acr: "0", // Basic password authentication amr: ["pwd"],};The 'sub' (subject) claim is the only guaranteed stable identifier. Email addresses can change. Names can change. But 'sub' is immutable for a given user at a given issuer. Always use 'sub' (combined with 'iss') as your primary key for user records—never rely on email as the unique identifier.
OIDC authentication builds on OAuth 2.0 Authorization Code flow with specific additions for identity. The key differences:
The Flow:
| Step | Description | OIDC-Specific Elements |
|---|---|---|
| Client redirects user to authorization endpoint | scope=openid, nonce parameter |
| User authenticates with IdP (password, MFA, etc.) | IdP handles all auth complexity |
| User approves requested scopes | Standard OIDC scopes shown clearly |
| Redirect back with authorization code | Same as OAuth 2.0 |
| Exchange code for tokens | Returns ID token + access token |
| Verify ID token signature, claims | Comprehensive validation required |
| Fetch additional claims via UserInfo endpoint | Access token authenticates request |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
// Complete OIDC Authentication Flow Implementationimport * as jose from 'jose'; interface OIDCConfig { clientId: string; clientSecret: string; redirectUri: string; issuer: string; scopes: string[];} interface TokenResponse { access_token: string; id_token: string; token_type: string; expires_in: number; refresh_token?: string; scope: string;} class OIDCClient { private discoveryDoc: any = null; private jwks: jose.JWTVerifyGetKey | null = null; constructor(private config: OIDCConfig) {} // Step 1: Fetch OIDC discovery document async initialize(): Promise<void> { const discoveryUrl = `${this.config.issuer}/.well-known/openid-configuration`; const response = await fetch(discoveryUrl); this.discoveryDoc = await response.json(); // Fetch JWKS for token verification this.jwks = jose.createRemoteJWKSet( new URL(this.discoveryDoc.jwks_uri) ); } // Step 2: Generate authorization URL generateAuthUrl(state: string, nonce: string): string { const url = new URL(this.discoveryDoc.authorization_endpoint); url.searchParams.set('client_id', this.config.clientId); url.searchParams.set('redirect_uri', this.config.redirectUri); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', this.config.scopes.join(' ')); url.searchParams.set('state', state); url.searchParams.set('nonce', nonce); // OIDC-specific: replay protection return url.toString(); } // Step 3: Exchange code for tokens async exchangeCode(code: string): Promise<TokenResponse> { const response = await fetch(this.discoveryDoc.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uri: this.config.redirectUri, }), }); if (!response.ok) { throw new Error(`Token request failed: ${response.status}`); } return response.json(); } // Step 4: Validate ID token (CRITICAL!) async validateIdToken( idToken: string, expectedNonce: string ): Promise<jose.JWTPayload> { if (!this.jwks) { throw new Error('Client not initialized'); } // Verify signature and standard claims const { payload, protectedHeader } = await jose.jwtVerify( idToken, this.jwks, { issuer: this.config.issuer, audience: this.config.clientId, maxTokenAge: '10 minutes', // ID tokens should be fresh } ); // Additional OIDC-specific validations // 1. Verify nonce matches (prevents replay/injection) if (payload.nonce !== expectedNonce) { throw new Error('Invalid nonce - possible token injection'); } // 2. iat (issued at) should be in the past if (payload.iat && payload.iat > Math.floor(Date.now() / 1000)) { throw new Error('Token issued in the future'); } // 3. If azp (authorized party) present, must match client_id if (payload.azp && payload.azp !== this.config.clientId) { throw new Error('Token not authorized for this client'); } return payload; } // Optional: Fetch additional user info async getUserInfo(accessToken: string): Promise<any> { const response = await fetch(this.discoveryDoc.userinfo_endpoint, { headers: { 'Authorization': `Bearer ${accessToken}` }, }); return response.json(); } // Complete authentication flow async handleCallback( code: string, expectedState: string, actualState: string, expectedNonce: string ): Promise<{ user: any; tokens: TokenResponse }> { // Verify state if (actualState !== expectedState) { throw new Error('Invalid state - CSRF detected'); } // Exchange code for tokens const tokens = await this.exchangeCode(code); // Validate ID token const idTokenClaims = await this.validateIdToken( tokens.id_token, expectedNonce ); // Optionally fetch more user info const userInfo = await this.getUserInfo(tokens.access_token); return { user: { ...idTokenClaims, ...userInfo }, tokens, }; }} // Usageconst oidcClient = new OIDCClient({ clientId: 'your-client-id', clientSecret: 'your-client-secret', redirectUri: 'https://yourapp.com/callback', issuer: 'https://accounts.google.com', scopes: ['openid', 'email', 'profile'],}); await oidcClient.initialize();ID token validation is not optional—it's the core security guarantee of OIDC. Verify the signature using the IdP's public keys. Verify issuer, audience, expiration, and nonce. Skipping these checks means you're trusting whoever can construct a JWT, not who the IdP authenticated.
OIDC standardizes how clients discover provider capabilities and public keys—no more hardcoding endpoints or manually managing certificates.
Discovery Document:
Every OIDC provider publishes a JSON document at /.well-known/openid-configuration containing all endpoints and capabilities. This enables dynamic configuration and automatic handling of endpoint changes.
12345678910111213141516171819202122
{ "issuer": "https://accounts.google.com", "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", "token_endpoint": "https://oauth2.googleapis.com/token", "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", "revocation_endpoint": "https://oauth2.googleapis.com/revoke", "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", "scopes_supported": ["openid", "email", "profile", "phone", "address"], "response_types_supported": ["code", "id_token", "code id_token"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "claims_supported": [ "sub", "iss", "aud", "exp", "iat", "nonce", "email", "email_verified", "name", "given_name", "family_name", "picture", "locale" ], "code_challenge_methods_supported": ["S256", "plain"], "token_endpoint_auth_methods_supported": [ "client_secret_basic", "client_secret_post" ]}JSON Web Key Set (JWKS):
The jwks_uri points to the provider's public keys used to sign ID tokens. Clients fetch these keys to verify token signatures without sharing private keys.
1234567891011121314151617181920
{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "abc123def456", "n": "0vx7agoebGcQ...(modulus in base64url)...", "e": "AQAB" }, { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "xyz789ghi012", "n": "1hqLlFJx9vZb...(modulus in base64url)...", "e": "AQAB" } ]}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Production-Ready JWKS Handling with Cachingimport * as jose from 'jose'; class JWKSManager { private jwksCache: Map<string, { keys: jose.JWTVerifyGetKey; fetchedAt: Date; }> = new Map(); private readonly cacheTTL = 60 * 60 * 1000; // 1 hour async getVerificationKey(issuer: string): Promise<jose.JWTVerifyGetKey> { const cached = this.jwksCache.get(issuer); if (cached && Date.now() - cached.fetchedAt.getTime() < this.cacheTTL) { return cached.keys; } // Fetch discovery document const discoveryUrl = `${issuer}/.well-known/openid-configuration`; const discovery = await fetch(discoveryUrl).then(r => r.json()); // Create JWKS client const keys = jose.createRemoteJWKSet(new URL(discovery.jwks_uri)); this.jwksCache.set(issuer, { keys, fetchedAt: new Date() }); return keys; } async verifyToken( token: string, expectedIssuer: string, expectedAudience: string ): Promise<jose.JWTPayload> { const keys = await this.getVerificationKey(expectedIssuer); try { const { payload } = await jose.jwtVerify(token, keys, { issuer: expectedIssuer, audience: expectedAudience, algorithms: ['RS256', 'ES256'], // Only allow expected algorithms }); return payload; } catch (error) { // If key not found, might be rotation - refresh and retry once if (error instanceof jose.errors.JWKSNoMatchingKey) { this.jwksCache.delete(expectedIssuer); const freshKeys = await this.getVerificationKey(expectedIssuer); const { payload } = await jose.jwtVerify(token, freshKeys, { issuer: expectedIssuer, audience: expectedAudience, algorithms: ['RS256', 'ES256'], }); return payload; } throw error; } }}Always explicitly specify allowed algorithms. The infamous 'alg: none' attack exploits libraries that blindly trust the token's algorithm claim. By whitelisting only RS256/ES256 (asymmetric), you prevent attackers from switching to symmetric algorithms or disabled signing.
While ID tokens contain identity claims, they're intentionally compact. The UserInfo endpoint provides a way to fetch additional claims about the authenticated user using the access token.
When to Use UserInfo:
When ID Token is Sufficient:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// UserInfo Endpoint Usageinterface UserInfoResponse { sub: string; // Always present - matches ID token sub name?: string; given_name?: string; family_name?: string; picture?: string; email?: string; email_verified?: boolean; phone_number?: string; phone_number_verified?: boolean; address?: { formatted?: string; street_address?: string; locality?: string; region?: string; postal_code?: string; country?: string; }; updated_at?: number; // Provider-specific claims may be included [key: string]: any;} class UserInfoService { constructor(private userInfoEndpoint: string) {} async getUserInfo(accessToken: string): Promise<UserInfoResponse> { const response = await fetch(this.userInfoEndpoint, { headers: { 'Authorization': `Bearer ${accessToken}`, }, }); if (response.status === 401) { throw new Error('Access token invalid or expired'); } if (!response.ok) { throw new Error(`UserInfo request failed: ${response.status}`); } const userInfo = await response.json(); // Validate sub claim matches ID token if available return userInfo; } // Fetch and merge with ID token claims async getCompleteProfile( idTokenClaims: any, accessToken: string ): Promise<UserInfoResponse> { const userInfo = await this.getUserInfo(accessToken); // Validate consistency if (userInfo.sub !== idTokenClaims.sub) { throw new Error('UserInfo sub does not match ID token'); } // Merge claims (userInfo takes precedence for freshness) return { ...idTokenClaims, ...userInfo, }; }} // Efficient pattern: Only fetch if neededasync function getUserDetails( idTokenClaims: any, accessToken: string, requiredFields: string[]): Promise<any> { const missingFields = requiredFields.filter( field => !(field in idTokenClaims) || idTokenClaims[field] == null ); if (missingFields.length === 0) { // ID token has everything we need return idTokenClaims; } // Need to fetch additional info const userInfoService = new UserInfoService( 'https://accounts.google.com/userinfo/v2/me' ); return userInfoService.getCompleteProfile(idTokenClaims, accessToken);}The same claims can appear in both ID tokens and UserInfo responses. ID token claims are captured at token issuance (snapshot in time). UserInfo returns current data. For frequently changing data like profile pictures, prefer UserInfo. For immutable data like sub, the ID token is sufficient.
OIDC standardizes scopes and the claims they grant access to. This predictability makes integration consistent across providers.
The Scope-to-Claims Mapping:
| Scope | Claims Provided | Notes |
|---|---|---|
| openid | sub | Required for OIDC; triggers ID token issuance |
| profile | name, family_name, given_name, middle_name, nickname, preferred_username, picture, website, gender, birthdate, zoneinfo, locale, updated_at | User profile information |
| email, email_verified | Email address and verification status | |
| phone | phone_number, phone_number_verified | Phone number and verification status |
| address | address (nested object) | Physical mailing address |
| offline_access | (none - affects token response) | Requests refresh token for offline access |
Requesting Specific Claims:
Beyond scopes, OIDC allows requesting specific claims via the claims parameter. This is useful when you need only certain claims from a scope, or when you need claims not covered by standard scopes.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Requesting specific claimsconst claimsParam = { id_token: { // Required in ID token email: { essential: true }, email_verified: { essential: true }, // Optional in ID token picture: null, }, userinfo: { // These claims only needed from UserInfo given_name: null, family_name: null, phone_number: { essential: true }, },}; // Build authorization URL with claims parameterconst authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');authUrl.searchParams.set('client_id', clientId);authUrl.searchParams.set('redirect_uri', redirectUri);authUrl.searchParams.set('response_type', 'code');authUrl.searchParams.set('scope', 'openid email profile phone');authUrl.searchParams.set('claims', JSON.stringify(claimsParam));authUrl.searchParams.set('state', state);authUrl.searchParams.set('nonce', nonce); // Principle of Least Privilege: Request Only What You Needconst minimalScopes = 'openid email'; // vs 'openid profile email phone address' // Good: Request only necessary claimsfunction getRequiredScopes(features: string[]): string { const scopes = new Set(['openid']); // Always required if (features.includes('email-notifications')) { scopes.add('email'); } if (features.includes('personalized-greeting')) { scopes.add('profile'); // For name } if (features.includes('sms-verification')) { scopes.add('phone'); } if (features.includes('offline-sync')) { scopes.add('offline_access'); // Refresh tokens } return Array.from(scopes).join(' ');}Don't request all possible scopes upfront. Request 'openid email' for login, then ask for 'phone' only when the user tries to enable SMS 2FA. This progressive consent improves sign-up conversion—users are more likely to approve minimal permissions initially.
Implementing OIDC correctly requires attention to several security and usability considerations. Here are patterns used by experienced engineering teams:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Secure Account Linking Implementationinterface OIDCIdentity { issuer: string; // e.g., "https://accounts.google.com" subject: string; // e.g., "110022334455667788" email?: string; emailVerified?: boolean;} interface UserAccount { id: string; primaryEmail: string; identities: OIDCIdentity[]; createdAt: Date;} class AccountLinkingService { constructor(private db: Database) {} async findOrCreateUser(oidcIdentity: OIDCIdentity): Promise<UserAccount> { // Step 1: Look for existing identity link let user = await this.findUserByIdentity( oidcIdentity.issuer, oidcIdentity.subject ); if (user) { return user; } // Step 2: If email is verified, look for existing account to link if (oidcIdentity.email && oidcIdentity.emailVerified) { const existingByEmail = await this.findUserByVerifiedEmail( oidcIdentity.email ); if (existingByEmail) { // Link this new identity to existing account await this.addIdentityToUser(existingByEmail.id, oidcIdentity); return existingByEmail; } } // Step 3: Create new account return this.createUser(oidcIdentity); } private async findUserByIdentity( issuer: string, subject: string ): Promise<UserAccount | null> { return this.db.users.findFirst({ where: { identities: { some: { issuer, subject }, }, }, }); } private async findUserByVerifiedEmail( email: string ): Promise<UserAccount | null> { // Only link if an existing identity has this email verified return this.db.users.findFirst({ where: { identities: { some: { email, emailVerified: true, }, }, }, }); } private async addIdentityToUser( userId: string, identity: OIDCIdentity ): Promise<void> { await this.db.userIdentities.create({ data: { userId, issuer: identity.issuer, subject: identity.subject, email: identity.email, emailVerified: identity.emailVerified || false, }, }); } private async createUser(identity: OIDCIdentity): Promise<UserAccount> { return this.db.users.create({ data: { id: generateUUID(), primaryEmail: identity.email || '', identities: { create: { issuer: identity.issuer, subject: identity.subject, email: identity.email, emailVerified: identity.emailVerified || false, }, }, createdAt: new Date(), }, }); }}Don't automatically link accounts just because emails match. A malicious IdP could claim any email. Only link when email_verified is true AND you trust the issuer. Even then, consider requiring user confirmation for linking to prevent account takeover scenarios.
OpenID Connect transforms OAuth 2.0 from an authorization framework into a complete identity solution. We've covered the essential knowledge for building secure OIDC integrations:
What's Next:
We've covered how to obtain tokens (OAuth 2.0 flows), how to manage tokens (access and refresh tokens), and how to verify identity (OIDC). The next page focuses on token validation—the critical step where your application verifies that tokens are authentic, current, and properly scoped before granting access.
You now have comprehensive knowledge of OpenID Connect—how it extends OAuth 2.0 with standardized identity, how ID tokens work, how to validate them securely, and how to implement account linking safely. You can integrate with any OIDC provider and build robust authentication for your applications. Next, we dive deep into token validation.