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
- In Skycloak, navigate to your cluster → Applications → Create Application
- Set Application Type to
Confidential(the client secret stays on the Express server) - Add Redirect URIs:
http://localhost:3000/callbackfor local dev, plus your production URL - Copy the Client ID and Client Secret from the credentials tab
2. Install Dependencies
npm install express-openid-connect3. 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-randGenerate SESSION_SECRET with:
openssl rand -hex 324. 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, andSKYCLOAK_ISSUER_BASE_URLvia your deployment platform’s secret manager, not a committed.env. - Behind a reverse proxy or load balancer, call
app.set('trust proxy', 1)before mountingauth()so cookies and redirect URLs use the correct scheme. -
express-openid-connectstores 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
-
invalid_redirect_urifrom Skycloak —baseURL+routes.callbackmust exactly match a Redirect URI registered on the Application (http://localhost:3000/callbackin this guide). -
req.oidcisundefined— confirmauth()middleware is registered before your routes; it must run first to populatereq.oidcon every request. -
Session lost immediately after login — check
SESSION_SECRETis set and consistent across restarts/instances; a changing or per-instance secret invalidates existing session cookies.
Next Steps
-
Node.js Integration Guide - Manual
openid-clientwiring for full control over the flow - Add social login providers
- Configure multi-factor authentication