Examples

July 3, 2026 · View on GitHub

  1. Basic setup
  2. Require authentication for specific routes
  3. Route customization
  4. Obtaining access tokens to call external APIs
  5. Obtaining and using refresh tokens
  6. Calling userinfo
  7. Protect a route based on specific claims
  8. Logout from Identity Provider
  9. Validate Claims from an ID token before logging a user in
  10. Use a custom session store
  11. Back-Channel Logout
  12. Custom Token Exchange
  13. Use a proxy for OIDC requests
  14. Session expiry from upstream IdP (IPSIE session_expiry)

1. Basic setup

The simplest use case for this middleware. By default all routes are protected. The middleware uses the Implicit Flow with Form Post to acquire an ID Token from the authorization server and an encrypted cookie session to persist it.

# .env
ISSUER_BASE_URL=https://YOUR_DOMAIN
CLIENT_ID=YOUR_CLIENT_ID
BASE_URL=https://YOUR_APPLICATION_ROOT_URL
SECRET=LONG_RANDOM_STRING
// basic.js
const express = require('express');
const { auth } = require('express-openid-connect');

const app = express();

app.use(auth());

app.get('/', (req, res) => {
  res.send(`hello ${req.oidc.user.sub}`);
});

What you get:

  • Every route after the auth() middleware requires authentication.
  • If a user tries to access a resource without being authenticated, the application will redirect the user to log in. After completion the user is redirected back to the resource.
  • The application creates /login and /logout GET routes.

Full example at basic.js, to run it: npm run start:example -- basic

2. Require authentication for specific routes

If your application has routes accessible to anonymous users, you can enable authorization per route:

const { auth, requiresAuth } = require('express-openid-connect');

app.use(
  auth({
    authRequired: false,
  }),
);

// Anyone can access the homepage
app.get('/', (req, res) => {
  res.send('<a href="/admin">Admin Section</a>');
});

// requiresAuth checks authentication.
app.get('/admin', requiresAuth(), (req, res) =>
  res.send(`Hello ${req.oidc.user.sub}, this is the admin section.`),
);

Full example at routes.js, to run it: npm run start:example -- routes

3. Route customization

If you need to customize the provided login, logout, and callback routes, you can disable the default routes and write your own route handler and pass custom paths to mount the handler at that path.

When overriding the callback route you should pass a authorizationParams.redirect_uri value on res.oidc.login and a redirectUri value on your res.oidc.callback call.

app.use(
  auth({
    routes: {
      // Override the default login route to use your own login route as shown below
      login: false,
      // Pass a custom path to redirect users to a different
      // path after logout.
      postLogoutRedirect: '/custom-logout',
      // Override the default callback route to use your own callback route as shown below
      callback: false,
    },
  }),
);

app.get('/login', (req, res) =>
  res.oidc.login({
    returnTo: '/profile',
    authorizationParams: {
      redirect_uri: 'http://localhost:3000/callback',
    },
  }),
);

app.get('/custom-logout', (req, res) => res.send('Bye!'));

app.get('/callback', (req, res) =>
  res.oidc.callback({
    redirectUri: 'http://localhost:3000/callback',
  }),
);

app.post('/callback', express.urlencoded({ extended: false }), (req, res) =>
  res.oidc.callback({
    redirectUri: 'http://localhost:3000/callback',
  }),
);

module.exports = app;

Please note that the login and logout routes are not required. Trying to access any protected resource triggers a redirect directly to Auth0 to login. These are helpful if you need to provide user-facing links to login or logout.

Full example at custom-routes.js, to run it: npm run start:example -- custom-routes

4. Obtaining access tokens to call external APIs

If your application needs an access token for external APIs you can request one by adding code to your response_type. The Access Token will be available on the request context:

app.use(
  auth({
    authorizationParams: {
      response_type: 'code', // This requires you to provide a client secret
      audience: 'https://api.example.com/products',
      scope: 'openid profile email read:products',
    },
  }),
);

app.get('/', async (req, res) => {
  let { token_type, access_token } = req.oidc.accessToken;
  const products = await request.get('https://api.example.com/products', {
    headers: {
      Authorization: `${token_type} ${access_token}`,
    },
  });
  res.send(`Products: ${products}`);
});

Full example at access-an-api.js, to run it: npm run start:example -- access-an-api

5. Obtaining and using refresh tokens

Refresh tokens can be requested along with access tokens using the offline_access scope during login. On a route that calls an API, check for an expired token and attempt a refresh:

app.use(
  auth({
    authorizationParams: {
      response_type: 'code', // This requires you to provide a client secret
      audience: 'https://api.example.com/products',
      scope: 'openid profile email offline_access read:products',
    },
  }),
);

app.get('/', async (req, res) => {
  let { token_type, access_token, isExpired, refresh } = req.oidc.accessToken;
  if (isExpired()) {
    ({ access_token } = await refresh());
  }
  const products = await request.get('https://api.example.com/products', {
    headers: {
      Authorization: `${token_type} ${access_token}`,
    },
  });
  res.send(`Products: ${products}`);
});

Full example at access-an-api.js, to run it: npm run start:example -- access-an-api

6. Calling userinfo

If your application needs to call the /userinfo endpoint you can use the fetchUserInfo method on the request context:

app.use(auth());

app.get('/', async (req, res) => {
  const userInfo = await req.oidc.fetchUserInfo();
  // ...
});

Full example at userinfo.js, to run it: npm run start:example -- userinfo

7. Protect a route based on specific claims

You can check a user's specific claims to determine if they can access a route:

const {
  auth,
  claimEquals,
  claimIncludes,
  claimCheck,
} = require('express-openid-connect');

app.use(
  auth({
    authRequired: false,
  }),
);

// claimEquals checks if a claim equals the given value
app.get('/admin', claimEquals('isAdmin', true), (req, res) =>
  res.send(`Hello ${req.oidc.user.sub}, this is the admin section.`),
);

// claimIncludes checks if a claim includes all the given values
app.get(
  '/sales-managers',
  claimIncludes('roles', 'sales', 'manager'),
  (req, res) =>
    res.send(`Hello ${req.oidc.user.sub}, this is the sales managers section.`),
);

// claimCheck takes a function that checks the claims and returns true to allow access
app.get(
  '/payroll',
  claimCheck(({ isAdmin, roles }) => isAdmin || roles.includes('payroll')),
  (req, res) =>
    res.send(`Hello ${req.oidc.user.sub}, this is the payroll section.`),
);

8. Logout from Identity Provider

When using an IDP, such as Auth0, the default configuration will only log the user out of your application session. When the user logs in again, they will be automatically logged back in to the IDP session. To have the user additionally logged out of the IDP session you will need to add idpLogout: true to the middleware configuration.

const { auth } = require('express-openid-connect');

app.use(
  auth({
    idpLogout: true,
    // auth0Logout: true // if using custom domain with Auth0
  }),
);

9. Validate Claims from an ID token before logging a user in

The afterCallback hook can be used to do validation checks on claims after the ID token has been received in the callback phase.

const { decodeJwt } = require('jose'); // jose v6 named export

app.use(
  auth({
    afterCallback: (req, res, session) => {
      const claims = decodeJwt(session.id_token);
      if (claims.org_id !== 'Required Organization') {
        throw new Error('User is not a part of the Required Organization');
      }
      return session;
    },
  }),
);

In this example, the application is validating the org_id to verify that the ID Token was issued to the correct Organization. Organizations is a set of features of Auth0 that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications.

If you don't know the Organization upfront, then your application should validate the claim to ensure that the value received is expected or known and that it corresponds to an entity your application trusts, such as a paying customer. If the claim cannot be validated, then the application should deem the token invalid. See https://auth0.com/docs/organizations/using-tokens for more info.

10. Use a custom session store

By default the session is stored in an encrypted cookie. But when the session gets too large it can bump up against the limits of the platform's max header size (16KB for Node >= 14, 8KB for Node <14). In these instances you can use a custom session store. The store should have get, set and destroy methods, making it compatible with express-session stores.

const { auth } = require('express-openid-connect');
const { createClient } = require('redis');
const RedisStore = require('connect-redis')(auth);

// redis@v4
let redisClient = createClient({ legacyMode: true });
redisClient.connect().catch(console.error);

// redis@v3
let redisClient = createClient();

app.use(
  auth({
    session: {
      store: new RedisStore({ client: redisClient }),
    },
  }),
);

Full example at custom-session-store.js, to run it: npm run start:example -- custom-session-store

11. Back-Channel Logout

Configure the SDK with backchannelLogout enabled. You will also need a session store (like Redis) - you can use any express-session compatible store.

// index.js
const { auth } = require('express-openid-connect');
const { createClient } = require('redis');
const RedisStore = require('connect-redis')(auth);

// redis@v4
let redisClient = createClient({ legacyMode: true });
redisClient.connect();

app.use(
  auth({
    idpLogout: true,
    backchannelLogout: {
      store: new RedisStore({ client: redisClient }),
    },
  }),
);

If you're already using a session store for stateful sessions you can just reuse that.

app.use(
  auth({
    idpLogout: true,
    session: {
      store: new RedisStore({ client: redisClient }),
    },
    backchannelLogout: true,
  }),
);

This will:

  • Create the handler /backchannel-logout that you can register with your Identity Provider.
  • On receipt of a valid Logout Token, the SDK will store an entry by sid (Session ID) and an entry by sub (User ID) in the backchannelLogout.store - the expiry of the entry will be set to the duration of the session (this is customisable using the onLogoutToken config hook)
  • On all authenticated requests, the SDK will check the store for an entry that corresponds with the session's ID token's sid or sub. If it finds a corresponding entry it will invalidate the session and clear the session cookie. (This is customisable using the isLoggedOut config hook)
  • If the user logs in again, the SDK will remove any stale sub entry in the Back-Channel Logout store to ensure they are not logged out immediately (this is customisable using the onLogin config hook)

The config options are documented here

12. Custom Token Exchange

When your app logs in with one API audience but needs to call a different downstream service, customTokenExchange() lets you swap the session's access token for one accepted by that service. This follows the OAuth 2.0 Token Exchange spec (RFC 8693).

const { auth, requiresAuth } = require('express-openid-connect');
const axios = require('axios');

app.use(
  auth({
    authorizationParams: {
      response_type: 'code',
      // Token issued at login is scoped to the upstream API
      audience: 'https://api.example.com',
      scope: 'openid profile offline_access',
    },
  }),
);

app.get('/reports', requiresAuth(), async (req, res, next) => {
  try {
    // Exchange the session token for one accepted by the reporting service.
    // subject_token and subject_token_type are resolved automatically.
    const { access_token } = await req.oidc.customTokenExchange({
      audience: 'https://reports.internal.example.com',
      scope: 'openid read:reports',
    });

    const { data } = await axios.get(
      'https://reports.internal.example.com/v1/summary',
      { headers: { Authorization: `Bearer ${access_token}` } },
    );

    res.json(data);
  } catch (err) {
    // Authorization server rejections surface as HTTP 400 and 401
    // with err.error and err.error_description set
    next(err);
  }
});

subject_token is resolved automatically from the session's accessToken and subject_token_type defaults to urn:ietf:params:oauth:token-type:access_token. The returned token is ephemeral — it is not stored in the session, so use it within the same request.

Delegation / impersonation

Use actor_token and actor_token_type to perform a delegation exchange (RFC 8693). The response includes an act claim identifying the acting party.

app.post('/impersonate', requiresAuth(), async (req, res, next) => {
  try {
    const { customerSubjectToken, customerSubjectTokenType } = req.body;

    const tokenSet = await req.oidc.customTokenExchange({
      subject_token: customerSubjectToken,
      subject_token_type: customerSubjectTokenType,
      actor_token: req.oidc.accessToken.access_token,
      actor_token_type: 'urn:ietf:params:oauth:token-type:access_token',
      audience: 'https://api.example.com/support',
    });

    // tokenSet.act identifies the acting party: { sub: '<agent-sub>' }
    res.json({ access_token: tokenSet.access_token, act: tokenSet.act });
  } catch (err) {
    next(err);
  }
});

actor_token_type is required when actor_token is provided.

Organization-scoped exchange

Use organization to issue a token bound to a specific organization:

const tokenSet = await req.oidc.customTokenExchange({
  audience: 'https://api.example.com/products',
  scope: 'read:products',
  organization: 'org_abc123',
});

Vendor-specific parameters

Parameters not covered by the named options (e.g., Token Vault connection) can be passed through the extra option:

const tokenSet = await req.oidc.customTokenExchange({
  audience: 'https://downstream-api.example.com',
  scope: 'read:data',
  extra: {
    connection: 'google-oauth2',
  },
});

13. Use a proxy for OIDC requests

If you need to route all OIDC HTTP requests (discovery, token, userinfo, etc.) through a proxy, use the customFetch option with undici's ProxyAgent:

const express = require('express');
const { auth } = require('express-openid-connect');
const { ProxyAgent, fetch: undiciFetch } = require('undici');

const app = express();

const dispatcher = new ProxyAgent('http://proxy.example.com:8080');

app.use(
  auth({
    customFetch: (url, options) => undiciFetch(url, { ...options, dispatcher }),
    // ... other options
  }),
);

The SDK wraps your customFetch function to add required headers (User-Agent, Auth0-Client telemetry) before making requests.

14. Session expiry from upstream IdP (IPSIE session_expiry)

When an upstream IdP supports the IPSIE SL1 spec, it can include a session_expiry claim in the ID token — an absolute Unix timestamp (seconds) marking the latest moment the IdP considers the session valid.

Emitting the claim

How the claim is included in the ID token depends on your authorization server. For example on Auth0, it is emitted on okta and oidc enterprise connections configured with id_token_session_expiry_supported: true, computing it as the earliest of the tenant's absolute session lifetime, the upstream IdP's own session expiry, and any value set via api.session.setExpiresAt in a Post-Login Action. For the canonical Action setup, see the Auth0 documentation (link to be added once the session_expiry Action guide is published).

Warning

session_expiry must be a Unix timestamp in seconds. The SDK rejects implausibly large values (anything at or above 10,000,000,000, ≈ year 2286) as malformed and treats them as "no ceiling", so a milliseconds value will silently disable enforcement rather than expiring the session ~55,000 years from now. Any other malformed value — non-integer, float, zero, or negative — also fails open. If your authorization server computes this value from a millisecond timestamp, ensure it divides by 1000 before including it in the ID token. For example, in an Auth0 Post-Login Action, make sure to convert the timestamp to seconds before setting the claim.

What the SDK does automatically

No configuration or code change is required. When the claim is present, the SDK handles everything automatically:

  • Persists the ceiling as sessionExpiresAt (Unix seconds) on the session.
  • Rejects login with HTTP 400 if the ceiling is already in the past at callback time, so a born-dead session is never persisted.
  • Treats the session as expired once sessionExpiresAt is reached on every request, with a 30-second leeway for clock skew.
  • Throws SessionExpiredError on accessToken.refresh() instead of making a token endpoint call that would fail anyway.
  • Caps the session cookie lifetime at the ceiling as a defense-in-depth backstop.

This is layered on top of your existing idle and absolute session timeouts — the session ends at whichever limit is reached first.

Behavior on expiry

  • Session reads: req.appSession is cleared and req.oidc.isAuthenticated() returns false. Your existing redirect-to-login path runs unchanged.
  • Token refresh: req.oidc.accessToken.refresh() throws SessionExpiredError (error.code === 'ERR_SESSION_EXPIRED', error.status === 401). Catch it to redirect the user to log in again.
const { SessionExpiredError } = require('express-openid-connect');

app.get('/resource', async (req, res, next) => {
  try {
    let { token_type, access_token, isExpired, refresh } = req.oidc.accessToken;
    if (isExpired()) {
      ({ access_token } = await refresh());
    }
    // use access_token
  } catch (err) {
    if (err instanceof SessionExpiredError) {
      return res.redirect('/');
    }
    next(err);
  }
});

Reading the value (optional)

To show a "your session ends soon" prompt, read sessionExpiresAt off the session:

app.get('/status', (req, res) => {
  const { sessionExpiresAt } = req.appSession || {};
  if (sessionExpiresAt) {
    const remainingSeconds = sessionExpiresAt - Math.floor(Date.now() / 1000);
    res.json({ remainingSeconds });
  } else {
    res.json({});
  }
});

Upgrading existing apps

Once your IdP starts emitting session_expiry, req.appSession can be null for a previously logged-in user once the ceiling is reached. If your code assumed the session always exists after login, add a null check. Sessions created before the upgrade (or through connections without the claim) have no sessionExpiresAt and behave exactly as before.