OpenID Connect
OAuth 2.0 was designed for authorization - letting apps access resources. But developers kept using it for authentication - logging users in. This worked, but inconsistently.
OpenID Connect (OIDC) standardizes authentication on top of OAuth 2.0.
OAuth 2.0
Authorization
"Can this app access my photos?"
OpenID Connect
Authentication
"Who is this user?"
What OIDC Adds to OAuth
JWT with user identity
Standard profile API
name, email, picture, etc.
Auto-configuration
The ID Token
The ID token is a JWT that contains identity claims about the user.
{
"iss": "https://accounts.google.com",
"sub": "110248495921238986420",
"aud": "your-client-id.apps.googleusercontent.com",
"exp": 1699999999,
"iat": 1699996399,
"nonce": "abc123",
"email": "john@example.com",
"email_verified": true,
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/a/photo.jpg",
"given_name": "John",
"family_name": "Doe"
}
Standard OIDC Claims
| Claim | Description | Scope Required |
|---|---|---|
| sub | Unique user identifier | openid |
| name | Full name | profile |
| Email address | ||
| email_verified | Is email verified? | |
| picture | Profile photo URL | profile |
OIDC Flow in Action
The OIDC flow is identical to OAuth Authorization Code, but with identity added.
// Step 1: Authorization request with OIDC scopes
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile'); // OIDC scopes!
authUrl.searchParams.set('state', generateState());
authUrl.searchParams.set('nonce', generateNonce()); // OIDC: prevents replay attacks
// Step 2: Token exchange returns ID token too
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
code: authorizationCode,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
redirect_uri: 'https://yourapp.com/callback',
grant_type: 'authorization_code'
})
});
const tokens = await tokenResponse.json();
// {
// access_token: "ya29.a0...",
// id_token: "eyJhbGciOiJSUzI1NiIs...", <-- This is new!
// expires_in: 3599,
// token_type: "Bearer",
// scope: "openid email profile"
// }
// Step 3: Validate and decode ID token
const idToken = tokens.id_token;
const payload = decodeAndVerifyIdToken(idToken);
console.log(payload.email); // "john@example.com"
console.log(payload.name); // "John Doe"
console.log(payload.picture); // Profile photo URL
ID Token Validation
Never trust an ID token without validating it. Attackers can create fake JWTs. Validation ensures the token came from the expected issuer and wasn't tampered with.
// Using a library like jose for validation
import { jwtVerify, createRemoteJWKSet } from 'jose';
async function validateIdToken(idToken) {
// Fetch Google's public keys
const JWKS = createRemoteJWKSet(
new URL('https://www.googleapis.com/oauth2/v3/certs')
);
try {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: CLIENT_ID
});
// Additional checks
if (payload.nonce !== expectedNonce) {
throw new Error('Invalid nonce - possible replay attack');
}
return payload; // User info!
} catch (error) {
throw new Error('Invalid ID token: ' + error.message);
}
}
Validation Checklist
- Verify signature - Using issuer's public keys (JWKS)
- Check issuer (iss) - Must match expected auth server
- Check audience (aud) - Must include your client ID
- Check expiration (exp) - Token must not be expired
- Verify nonce - Matches what you sent (prevents replay)
The UserInfo Endpoint
Want more user details? Call the UserInfo endpoint with the access token.
// Get additional user info
const userInfoResponse = await fetch(
'https://openidconnect.googleapis.com/v1/userinfo',
{
headers: {
'Authorization': `Bearer ${accessToken}`
}
}
);
const userInfo = await userInfoResponse.json();
// {
// "sub": "110248495921238986420",
// "name": "John Doe",
// "given_name": "John",
// "family_name": "Doe",
// "picture": "https://lh3.googleusercontent.com/...",
// "email": "john@example.com",
// "email_verified": true,
// "locale": "en"
// }
OIDC Discovery
OIDC providers publish their configuration at a well-known URL:
https://accounts.google.com/.well-known/openid-configuration
{
"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",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"scopes_supported": ["openid", "email", "profile"],
"response_types_supported": ["code", "token", "id_token"],
"claims_supported": ["sub", "name", "email", "picture", ...]
}
With discovery, your app can automatically configure itself for any OIDC provider. Just fetch the discovery document and use the endpoints it specifies.
Real-World: "Sign in with Google"
Here's a complete implementation:
// 1. Configuration from discovery
const GOOGLE_CONFIG = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
jwksUri: 'https://www.googleapis.com/oauth2/v3/certs'
};
// 2. Login route - redirect to Google
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
const nonce = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
req.session.oauthNonce = nonce;
const authUrl = new URL(GOOGLE_CONFIG.authorizationEndpoint);
authUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID);
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid email profile');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('nonce', nonce);
res.redirect(authUrl.toString());
});
// 3. Callback route - exchange code for tokens
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state
if (state !== req.session.oauthState) {
return res.status(403).send('Invalid state');
}
// Exchange code for tokens
const tokenResponse = await fetch(GOOGLE_CONFIG.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://yourapp.com/callback',
grant_type: 'authorization_code'
})
});
const tokens = await tokenResponse.json();
// Validate ID token
const user = await validateIdToken(tokens.id_token, req.session.oauthNonce);
// Create or update user in database
const dbUser = await upsertUser({
googleId: user.sub,
email: user.email,
name: user.name,
picture: user.picture
});
// Create session
req.session.userId = dbUser.id;
res.redirect('/dashboard');
});
OIDC vs SAML
| Feature | OIDC | SAML |
|---|---|---|
| Format | JSON (JWT) | XML |
| Complexity | Simple | Complex |
| Mobile Support | Excellent | Poor |
| Use Case | Modern apps, APIs | Enterprise SSO |
Key Takeaways
- OIDC = OAuth + Identity - Standardized authentication layer
- ID Token is a JWT - Contains verified user identity
- Always validate ID tokens - Signature, issuer, audience, expiration
- Use discovery - Auto-configure from .well-known/openid-configuration
Next up: Security Best Practices - Common vulnerabilities and how to prevent them.