Understanding OpenID Connect: The Core Concepts
OpenID Connect (OIDC) is an identity layer built on top of the OAuth 2.0 protocol. It allows clients to verify the identity of an end-user based on the authentication performed by an authorization server, as well as to obtain basic profile information about the user in an interoperable and RESTful manner. Think of OAuth 2.0 as the framework that grants access to resources, while OpenID Connect adds the identity dimension, answering the question: "Who is currently using this application?"
The Key Players
Every OIDC interaction involves several distinct actors. Understanding their roles is critical before writing any code:
- End-User: The person who wants to sign in. They own the identity and credentials.
- Relying Party (RP): Your application — the website or mobile app that needs to know who the user is.
- OpenID Provider (OP): The trusted third-party service that authenticates the user and asserts their identity. Examples include Google, Microsoft Entra ID, Auth0, Okta, and self-hosted solutions like Keycloak.
The ID Token: The Heart of OIDC
Where OAuth 2.0 issues access tokens (opaque or JWT-based strings for API authorization), OpenID Connect introduces the ID Token — a JSON Web Token (JWT) containing claims about the authentication event. A typical decoded ID Token payload looks like this:
{
"iss": "https://accounts.google.com",
"sub": "110492634215678912",
"aud": "your-client-id.apps.googleusercontent.com",
"iat": 1698765432,
"exp": 1698769032,
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"email": "jane.doe@example.com",
"email_verified": true,
"picture": "https://lh3.googleusercontent.com/.../photo.jpg"
}
The sub claim is the unique identifier for the user at the OP — your application uses this to uniquely associate the user's account. The aud claim must match your client ID, and you must always validate iss, exp, and the cryptographic signature before trusting any claims.
Flows: Authorization Code with PKCE Is the Modern Standard
For server-side web applications, the Authorization Code Flow is the canonical approach. For single-page applications (SPAs) and native mobile apps, you must use the Authorization Code Flow with PKCE (Proof Key for Code Exchange) to prevent authorization code interception attacks. The implicit flow, once popular for SPAs, is now deprecated and should not be used in new implementations.
Here is the complete Authorization Code Flow with PKCE, step by step:
- The RP generates a cryptographically random code verifier and derives a code challenge from it (using SHA-256).
- The RP redirects the user's browser to the OP's authorize endpoint, passing the code challenge, client ID, redirect URI, and requested scopes.
- The OP authenticates the user (if not already logged in) and obtains consent.
- The OP redirects the browser back to the RP's callback URL with an authorization code.
- The RP exchanges the authorization code, along with the original code verifier, for tokens at the OP's token endpoint.
- The RP validates the ID token and optionally calls the UserInfo endpoint for additional claims.
Why OpenID Connect Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Implementing OIDC isn't merely a technical checkbox. It addresses fundamental security and user experience challenges:
- No more password storage: Your application never sees or stores the user's credentials. Authentication is delegated to the OP, which can enforce multi-factor authentication, adaptive risk policies, and breach detection centrally.
- Federated identity: Users can sign in with an existing account they already trust — their Google login, corporate Azure AD account, or social identity. This reduces sign-up friction dramatically.
- Standardized claims: OIDC defines a set of standard claims (
name,email,picture, etc.) and a discovery mechanism (/.well-known/openid-configuration) that makes integration with any compliant OP predictable and straightforward. - Single Sign-On (SSO): Once authenticated at the OP, users can seamlessly access multiple RPs without repeated login prompts. Combined with session management endpoints, you can also implement single logout.
- Compliance ready: Regulations like GDPR and CCPA favor delegated identity because user data resides with the OP, and consent can be managed at the identity layer.
Practical Implementation: Building an OIDC Relying Party
Let's build a complete Node.js + Express RP that implements the Authorization Code Flow with PKCE. We will use the popular openid-client library, which is a certified OIDC implementation for Node.js. The OP will be any compliant provider — we'll configure it generically so you can plug in Google, Okta, or a local Keycloak instance.
Project Setup and Dependencies
mkdir oidc-rp-demo
cd oidc-rp-demo
npm init -y
npm install express express-session openid-client
# Development dependencies
npm install --save-dev dotenv
Your .env file should contain provider-specific configuration. Here is an example for a generic OP (adjust URLs and credentials accordingly):
# .env
PORT=3000
SESSION_SECRET=a-long-random-string-at-least-32-chars
OP_ISSUER=https://example-op.com
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
REDIRECT_URI=http://localhost:3000/callback
For public clients (SPAs, mobile apps) where a client secret cannot be safely stored, the flow works without the secret — PKCE handles the security.
Discovering the OP Configuration Automatically
One of the most powerful features of OIDC is the discovery document. Every compliant OP exposes a JSON document at /.well-known/openid-configuration that contains all endpoint URLs, supported scopes, and cryptographic keys. The openid-client library fetches and parses this automatically:
const express = require('express');
const session = require('express-session');
const { Issuer, generators } = require('openid-client');
require('dotenv').config();
async function initializeApp() {
// Discover the OP's endpoints from its issuer URL
const issuer = await Issuer.discover(process.env.OP_ISSUER);
console.log('Discovered OP endpoints:');
console.log(' Authorization: ', issuer.authorization_endpoint);
console.log(' Token: ', issuer.token_endpoint);
console.log(' UserInfo: ', issuer.userinfo_endpoint);
// Create the client (Relying Party) instance
const client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uris: [process.env.REDIRECT_URI],
response_types: ['code'], // Authorization Code Flow
token_endpoint_auth_method: 'client_secret_basic',
});
const app = express();
// Session middleware — stores PKCE code_verifier and token set
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set to true in production with HTTPS
httpOnly: true,
maxAge: 3600000, // 1 hour
},
}));
// ... route handlers will go here
app.listen(process.env.PORT, () => {
console.log(`RP running at http://localhost:${process.env.PORT}`);
});
}
initializeApp().catch(console.error);
The Login Route: Initiating the Flow with PKCE
When a user clicks "Sign In", we generate the PKCE code verifier and challenge, store the verifier in the session, and redirect to the OP:
// Route: GET /login
app.get('/login', (req, res) => {
// Generate PKCE code verifier and challenge
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
// Store the verifier in the session for the callback step
req.session.code_verifier = code_verifier;
req.session.save();
// Build the authorization URL with required parameters
const authUrl = client.authorizationUrl({
scope: 'openid profile email', // Standard scopes
code_challenge: code_challenge,
code_challenge_method: 'S256', // SHA-256
// Optional: state parameter for CSRF protection
state: generators.state(),
// Optional: nonce for replay protection on the ID token
nonce: generators.nonce(),
});
res.redirect(authUrl);
});
The openid scope is mandatory — it tells the OP that you're performing an OIDC authentication, not just OAuth authorization. profile and email are standard scopes that give you access to the UserInfo endpoint claims.
The Callback Route: Exchanging the Code for Tokens
After the user authenticates, the OP redirects back to your redirect_uri with a code and state query parameters. Here we exchange the code for tokens and validate the ID token:
// Route: GET /callback
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req);
try {
// Exchange the authorization code for tokens
// The code_verifier from the session is sent to validate PKCE
const tokenSet = await client.callback(
process.env.REDIRECT_URI,
params,
{
code_verifier: req.session.code_verifier,
state: req.session.state, // Optional but recommended
}
);
// tokenSet contains: access_token, id_token, refresh_token (if granted), scope, expires_at
// Validate and decode the ID token
const claims = tokenSet.claims();
console.log('ID Token claims:', claims);
// The claims() method automatically validates:
// - Signature using the OP's JWKS keys
// - iss (issuer)
// - aud (audience — must match client_id)
// - exp (expiration)
// - iat (issued at)
// - nonce (if you passed it in the authorization request)
// Store tokens and user info in session
req.session.tokenSet = tokenSet;
req.session.user = {
sub: claims.sub,
email: claims.email,
name: claims.name,
picture: claims.picture,
};
req.session.save();
res.redirect('/profile');
} catch (err) {
console.error('Callback error:', err);
res.status(500).send('Authentication failed');
}
});
Fetching Additional Claims from the UserInfo Endpoint
Sometimes the ID token doesn't contain all the claims you need (especially if the OP defers them to the UserInfo endpoint). You can fetch them explicitly:
// Route: GET /profile — displays user information
app.get('/profile', async (req, res) => {
if (!req.session.tokenSet) {
return res.redirect('/login');
}
// Fetch userinfo if needed for additional claims
const userinfo = await client.userinfo(req.session.tokenSet);
res.send(`
<h1>Welcome, ${userinfo.name || req.session.user.name}</h1>
<p>Email: ${userinfo.email}</p>
<p>Subject ID: ${userinfo.sub}</p>
<p>Picture: <img src="${userinfo.picture}" width="100"/></p>
<a href="/logout">Logout</a>
`);
});
Logout: Single Logout and RP-Initiated Logout
OIDC supports logout in two forms. The simplest is RP-initiated logout, where you clear your local session and optionally redirect to the OP's logout endpoint to also clear the OP session:
// Route: GET /logout
app.get('/logout', (req, res) => {
const tokenSet = req.session.tokenSet;
// Destroy local session first
req.session.destroy((err) => {
if (err) {
console.error('Session destruction error:', err);
}
// Optionally redirect to OP's end_session_endpoint
if (tokenSet) {
const logoutUrl = client.endSessionUrl({
id_token_hint: tokenSet.id_token,
post_logout_redirect_uri: 'http://localhost:3000',
});
res.redirect(logoutUrl || '/');
} else {
res.redirect('/');
}
});
});
The Complete Application File
Here is the entire app.js assembled together, with a landing page and error handling:
const express = require('express');
const session = require('express-session');
const { Issuer, generators } = require('openid-client');
require('dotenv').config();
async function initializeApp() {
const issuer = await Issuer.discover(process.env.OP_ISSUER);
const client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uris: [process.env.REDIRECT_URI],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic',
});
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: false,
httpOnly: true,
maxAge: 3600000,
},
}));
// Landing page
app.get('/', (req, res) => {
if (req.session.user) {
res.send(`<h1>Hello, ${req.session.user.name}</h1>
<a href="/profile">Profile</a> |
<a href="/logout">Logout</a>`);
} else {
res.send('<h1>Welcome</h1><a href="/login">Sign In</a>');
}
});
app.get('/login', (req, res) => {
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const state = generators.state();
const nonce = generators.nonce();
req.session.code_verifier = code_verifier;
req.session.state = state;
req.session.nonce = nonce;
req.session.save();
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge: code_challenge,
code_challenge_method: 'S256',
state: state,
nonce: nonce,
});
res.redirect(authUrl);
});
app.get('/callback', async (req, res) => {
const params = client.callbackParams(req);
try {
const tokenSet = await client.callback(
process.env.REDIRECT_URI,
params,
{
code_verifier: req.session.code_verifier,
state: req.session.state,
nonce: req.session.nonce,
}
);
const claims = tokenSet.claims();
req.session.tokenSet = tokenSet;
req.session.user = {
sub: claims.sub,
email: claims.email,
name: claims.name,
picture: claims.picture,
};
req.session.save();
res.redirect('/profile');
} catch (err) {
console.error('Callback error:', err);
res.status(500).send('Authentication failed: ' + err.message);
}
});
app.get('/profile', async (req, res) => {
if (!req.session.tokenSet) {
return res.redirect('/login');
}
try {
const userinfo = await client.userinfo(req.session.tokenSet);
res.send(`
<h1>Welcome, ${userinfo.name}</h1>
<p>Email: ${userinfo.email}</p>
<p>Subject: ${userinfo.sub}</p>
${userinfo.picture ? `<p><img src="${userinfo.picture}" width="100"/></p>` : ''}
<a href="/logout">Logout</a>
`);
} catch (err) {
console.error('Userinfo error:', err);
res.status(500).send('Failed to fetch userinfo');
}
});
app.get('/logout', (req, res) => {
const idToken = req.session.tokenSet?.id_token;
req.session.destroy((err) => {
if (err) console.error(err);
if (idToken) {
const logoutUrl = client.endSessionUrl({
id_token_hint: idToken,
post_logout_redirect_uri: 'http://localhost:3000',
});
res.redirect(logoutUrl || '/');
} else {
res.redirect('/');
}
});
});
app.listen(process.env.PORT, () => {
console.log(`RP running at http://localhost:${process.env.PORT}`);
});
}
initializeApp().catch(console.error);
Manual Token Validation (Without a Library)
If you're implementing OIDC in a language without a certified library, you must validate the ID token manually. Here's the pseudocode logic you must implement:
// 1. Decode the JWT header and payload (Base64URL decode)
// 2. Verify the signature:
// - Fetch the JWKS from the OP's jwks_uri (from discovery doc)
// - Find the key with kid matching the JWT header's kid
// - Use the key's public key (RS256, ES256, etc.) to verify the signature
// 3. Validate claims:
// - iss: Must match the expected issuer
// - aud: Must contain your client_id
// - exp: Must be > current time (with clock skew tolerance of ~60s)
// - iat: Optional, must be <= current time
// - nonce: If you sent a nonce, the ID token must contain it
// - azp: If aud is multi-valued, azp must match your client_id
// 4. If any validation fails, reject the token completely
const jwt = require('jsonwebtoken'); // Example using a JWT library
function validateIdToken(idToken, expectedIssuer, clientId, expectedNonce) {
// Fetch JWKS from expectedIssuer + '/.well-known/openid-configuration'
// ... get the signing key for the token's kid
const decoded = jwt.verify(idToken, signingKey, {
algorithms: ['RS256', 'ES256'], // Restrict to expected algorithms
issuer: expectedIssuer,
audience: clientId,
clockTolerance: 60, // seconds
});
if (expectedNonce && decoded.nonce !== expectedNonce) {
throw new Error('Nonce mismatch');
}
return decoded;
}
Best Practices for OIDC Implementation
1. Always Use PKCE, Even for Confidential Clients
PKCE is not just for public clients. Using it universally eliminates the entire class of authorization code injection attacks. There is no downside — it adds negligible computational overhead and is supported by every modern OP.
2. Validate the ID Token on Every Request (or Use Session Tokens)
Don't just validate the ID token during the callback and then trust session cookies blindly. Either validate the ID token on every API request (as a Bearer token) or use short-lived session tokens with refresh capabilities. If you store the ID token in a cookie, set it as HttpOnly, Secure, and with a SameSite policy of Lax or Strict.
3. Use the State Parameter to Prevent CSRF
The state parameter binds the authorization request to the user's session at the RP. Store it in the session before redirecting, and verify it in the callback. If the state doesn't match, abort the flow. This prevents attackers from injecting stolen authorization codes into a victim's session.
4. Implement Proper Redirect URI Validation
Your redirect URI must be pre-registered with the OP. Never accept dynamic redirect URIs from query parameters. If you need multiple redirect URIs (e.g., for different environments), register them all explicitly and select among them server-side.
5. Refresh Token Rotation and Replay Detection
If your application uses refresh tokens to obtain new access tokens without re-authentication, ensure the OP supports refresh token rotation. When a refresh token is used, a new refresh token is issued, and the old one is invalidated. Your RP must persist the new refresh token immediately and atomically. Failure to do so can cause the user to be logged out unexpectedly.
6. Scope Minimization
Request only the scopes you genuinely need. If you only need the user's unique identifier and email, request openid email — not profile or address. This respects user privacy and reduces the surface area for data exposure.
7. Token Storage Security
Store tokens securely:
- Server-side: Use encrypted session storage (e.g., a database-backed session store with encryption at rest). Never store tokens in browser-accessible storage like localStorage for SPAs.
- SPAs: Use the Auth Code Flow with PKCE and a lightweight backend (BFF pattern) that holds tokens server-side and uses short-lived cookies to communicate with the frontend.
- Mobile: Use platform-specific secure storage (iOS Keychain, Android EncryptedSharedPreferences).
8. Implement Graceful Token Expiry Handling
Access tokens are short-lived (typically 5-15 minutes). Your application must handle 401 responses by attempting a refresh token grant (if you have a refresh token) or by redirecting the user to re-authenticate. Implement retry logic with exponential backoff for transient network errors when calling the token endpoint.
9. Regularly Rotate Client Secrets
If you use a confidential client with a client secret, rotate it periodically and store it securely (e.g., in a secrets manager like HashiCorp Vault, AWS Secrets Manager, or environment variables injected at runtime — never hardcoded in source code).
10. Monitor OP Discovery Document Changes
The OP's /.well-known/openid-configuration can change over time — new JWKS keys rotate, endpoints may shift. If you cache the discovery document, set a reasonable TTL (e.g., refresh every hour) and handle cache invalidation gracefully.
Testing Your OIDC Integration
Before going live, verify your implementation against these scenarios:
- Happy path: User signs in successfully, claims are correct, session persists.
- Expired ID token: Your validation rejects it with a clear error.
- Wrong audience: An ID token intended for another client is rejected.
- Tampered signature: A modified JWT payload fails signature verification.
- Missing code_verifier: Token exchange fails if PKCE verifier doesn't match.
- Replay attack: An authorization code reused within the short window is rejected by the OP (this is enforced by most OPs automatically).
- CSRF with mismatched state: Your callback detects the mismatch and aborts.
- Token refresh: A refresh token rotation sequence works correctly across multiple concurrent requests (handle race conditions).
You can use tools like oidc-provider (a Node.js OP implementation for testing) or public sandbox providers (Auth0, Okta) to simulate these scenarios.
Conclusion
Implementing OpenID Connect transforms authentication from a bespoke, error-prone responsibility into a well-defined protocol with mature libraries and clear security boundaries. By adopting the Authorization Code Flow with PKCE, validating ID tokens rigorously, and following the best practices outlined here, you build a foundation that is both secure and maintainable. The discovery mechanism means your code works with any compliant provider, giving you flexibility to switch identity providers or support multiple OPs simultaneously. Start with the reference implementation above, adapt it to your framework of choice, and always test against both the happy path and the failure scenarios. Identity is the front door to your application — OpenID Connect helps you build that door correctly, using proven architectural patterns that protect both your users and your system.