Express.js Integration

Express.js Integration

This guide covers how to integrate Skycloak authentication into Express.js applications using express-openid-connect, a middleware purpose-built for session-based OIDC authorization-code flows in Express.

ℹ️

Already using openid-client directly?

The Node.js Integration Guide covers wiring openid-client by hand for full control over the flow. express-openid-connect wraps that same authorization-code flow into a drop-in middleware with less boilerplate — use it if you want route protection and session handling out of the box rather than building it yourself.

Prerequisites

  • Express 4.x or 5.x application
  • Node.js 18+
  • Skycloak cluster with configured realm and client
  • Basic understanding of Express middleware

Quick Start

1. Create an Application in Skycloak

  1. In Skycloak, navigate to your cluster → ApplicationsCreate Application
  2. Set Application Type to Confidential (the client secret stays on the Express server)
  3. Add Redirect URIs: http://localhost:3000/callback for local dev, plus your production URL
  4. Copy the Client ID and Client Secret from the credentials tab

2. Install Dependencies

npm install express-openid-connect

3. Configure Environment Variables

Create .env:

BASE_URL=http://localhost:3000
SKYCLOAK_CLIENT_ID=your-express-app
SKYCLOAK_CLIENT_SECRET=your-client-secret
SKYCLOAK_ISSUER_BASE_URL=https://your-cluster-id.app.skycloak.io/realms/your-realm
SESSION_SECRET=a-random-32-char-string-generated-with-openssl-rand

Generate SESSION_SECRET with:

openssl rand -hex 32

4. Wire Up the Middleware

// app.js
const express = require('express');
require('dotenv').config();
const { auth, requiresAuth } = require('express-openid-connect');

const app = express();

app.use(
  auth({
    authRequired: false, // opt in per-route with requiresAuth()
    auth0Logout: false,  // generic OIDC provider, not Auth0
    baseURL: process.env.BASE_URL,
    clientID: process.env.SKYCLOAK_CLIENT_ID,
    clientSecret: process.env.SKYCLOAK_CLIENT_SECRET,
    issuerBaseURL: process.env.SKYCLOAK_ISSUER_BASE_URL,
    secret: process.env.SESSION_SECRET,
    authorizationParams: {
      response_type: 'code',
      scope: 'openid profile email',
    },
    routes: {
      login: '/login',
      logout: '/logout',
      callback: '/callback',
    },
  })
);

app.get('/', (req, res) => {
  res.send(req.oidc.isAuthenticated() ? `Logged in as ${req.oidc.user.preferred_username}` : 'Logged out');
});

app.get('/dashboard', requiresAuth(), (req, res) => {
  res.json({ user: req.oidc.user });
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

The middleware auto-registers /login, /logout, and /callback routes and attaches req.oidc (with isAuthenticated(), user, and accessToken) to every request.

Role-Based Access Control

// middleware/requireRealmRole.js
function requireRealmRole(...roles) {
  return (req, res, next) => {
    if (!req.oidc.isAuthenticated()) {
      return res.status(401).json({ error: 'Not authenticated' });
    }

    const userRoles = req.oidc.user?.realm_access?.roles ?? [];
    const hasRole = roles.some((role) => userRoles.includes(role));

    if (!hasRole) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
}

module.exports = { requireRealmRole };
// routes/admin.js
const express = require('express');
const { requiresAuth } = require('express-openid-connect');
const { requireRealmRole } = require('../middleware/requireRealmRole');

const router = express.Router();

router.get('/admin/users', requiresAuth(), requireRealmRole('admin'), (req, res) => {
  res.json({ managedBy: req.oidc.user.sub });
});

module.exports = router;

Calling Downstream APIs with the Access Token

// routes/orders.js
const express = require('express');
const { requiresAuth } = require('express-openid-connect');
const axios = require('axios');

const router = express.Router();

router.get('/orders', requiresAuth(), async (req, res) => {
  try {
    const { data } = await axios.get('https://api.example.com/orders', {
      headers: { Authorization: `Bearer ${req.oidc.accessToken}` },
    });
    res.json(data);
  } catch (error) {
    res.status(502).json({ error: 'Upstream request failed' });
  }
});

module.exports = router;

Protecting a Pure API (No Browser Session)

For a service that only accepts bearer tokens from a separate SPA/mobile client (no login redirect from Express itself), validate incoming JWTs instead of using auth()’s session flow:

// middleware/verifyBearerToken.js
const { expressjwt } = require('express-jwt');
const jwksRsa = require('jwks-rsa');

const checkJwt = expressjwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: `${process.env.SKYCLOAK_ISSUER_BASE_URL}/protocol/openid-connect/certs`,
  }),
  issuer: process.env.SKYCLOAK_ISSUER_BASE_URL,
  algorithms: ['RS256'],
});

module.exports = { checkJwt };
// app.js (API-only variant)
const { checkJwt } = require('./middleware/verifyBearerToken');

app.get('/api/profile', checkJwt, (req, res) => {
  res.json({ sub: req.auth.sub, roles: req.auth.realm_access?.roles ?? [] });
});

Testing

// tests/requireRealmRole.test.js
const { requireRealmRole } = require('../middleware/requireRealmRole');

function mockReqRes(user) {
  const req = { oidc: { isAuthenticated: () => !!user, user } };
  const res = {
    statusCode: null,
    body: null,
    status(code) { this.statusCode = code; return this; },
    json(payload) { this.body = payload; return this; },
  };
  return { req, res };
}

test('rejects unauthenticated requests', () => {
  const { req, res } = mockReqRes(null);
  requireRealmRole('admin')(req, res, () => {});
  expect(res.statusCode).toBe(401);
});

test('rejects users without the required role', () => {
  const { req, res } = mockReqRes({ realm_access: { roles: ['user'] } });
  requireRealmRole('admin')(req, res, () => {});
  expect(res.statusCode).toBe(403);
});

test('calls next() for users with the required role', () => {
  const { req, res } = mockReqRes({ realm_access: { roles: ['admin'] } });
  const next = jest.fn();
  requireRealmRole('admin')(req, res, next);
  expect(next).toHaveBeenCalled();
});

Production Considerations

  • Set SESSION_SECRET, SKYCLOAK_CLIENT_SECRET, and SKYCLOAK_ISSUER_BASE_URL via your deployment platform’s secret manager, not a committed .env.
  • Behind a reverse proxy or load balancer, call app.set('trust proxy', 1) before mounting auth() so cookies and redirect URLs use the correct scheme.
  • express-openid-connect stores the session in an encrypted cookie by default; for multi-instance deployments behind a load balancer this works without sticky sessions, but keep cookies under the ~4KB browser limit if you add custom session data.

Troubleshooting

  1. invalid_redirect_uri from SkycloakbaseURL + routes.callback must exactly match a Redirect URI registered on the Application (http://localhost:3000/callback in this guide).
  2. req.oidc is undefined — confirm auth() middleware is registered before your routes; it must run first to populate req.oidc on every request.
  3. Session lost immediately after login — check SESSION_SECRET is set and consistent across restarts/instances; a changing or per-instance secret invalidates existing session cookies.

Next Steps

Last updated on