← Back to DevBytes

Implementing OpenID Connect: From Theory to Practice

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:

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:

  1. The RP generates a cryptographically random code verifier and derives a code challenge from it (using SHA-256).
  2. The RP redirects the user's browser to the OP's authorize endpoint, passing the code challenge, client ID, redirect URI, and requested scopes.
  3. The OP authenticates the user (if not already logged in) and obtains consent.
  4. The OP redirects the browser back to the RP's callback URL with an authorization code.
  5. The RP exchanges the authorization code, along with the original code verifier, for tokens at the OP's token endpoint.
  6. 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:

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles