Skip to main content

OIDC Authentication in Node.js / Express

Learn how to implement OIDC token validation in Express.js applications using middleware.

Overview​

This guide shows you how to build Express.js middleware that validates OIDC tokens from GitHub Actions, GitLab CI, Kubernetes, and other identity providers.

Prerequisites​

  • Node.js >= 18.0
  • Express.js >= 4.18
  • Basic understanding of Express middleware

Installation​

Install required packages:

npm install express jsonwebtoken jwks-rsa

Dependencies:

  • jsonwebtoken: JWT signing and verification
  • jwks-rsa: JWKS endpoint client with caching

Basic Implementation​

1. Create Authentication Middleware​

// middleware/auth.js
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// Configure allowed issuers and their JWKS endpoints
const allowedIssuers = {
'https://token.actions.githubusercontent.com': {
jwksUri: 'https://token.actions.githubusercontent.com/.well-known/jwks',
audience: 'https://api.yourservice.com'
},
'https://gitlab.com': {
jwksUri: 'https://gitlab.com/oauth/discovery/keys',
audience: 'https://api.yourservice.com'
}
};

// Create JWKS clients for each issuer
const jwksClients = {};
Object.entries(allowedIssuers).forEach(([issuer, config]) => {
jwksClients[issuer] = jwksClient({
jwksUri: config.jwksUri,
cache: true,
cacheMaxAge: 600000, // 10 minutes
rateLimit: true,
jwksRequestsPerMinute: 10
});
});

async function verifyToken(token) {
// Decode to get issuer (without verification)
const decoded = jwt.decode(token, { complete: true });
if (!decoded) {
throw new Error('Invalid token format');
}

const issuer = decoded.payload.iss;
const issuerConfig = allowedIssuers[issuer];
if (!issuerConfig) {
throw new Error('Unknown issuer');
}

// Get signing key
const client = jwksClients[issuer];
const getKey = (header, callback) => {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.publicKey || key.rsaPublicKey);
});
};

// Verify token
return new Promise((resolve, reject) => {
jwt.verify(token, getKey, {
algorithms: ['RS256'],
issuer: issuer,
audience: issuerConfig.audience,
clockTolerance: 60
}, (err, payload) => {
if (err) return reject(err);
resolve(payload);
});
});
}

// Middleware function
async function authenticate(req, res, next) {
try {
// Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}

const token = authHeader.substring(7);

// Verify token
const claims = await verifyToken(token);

// Add claims to request object
req.user = claims;
next();
} catch (error) {
console.error('Authentication failed:', error.message);
res.status(401).json({ error: 'Authentication failed' });
}
}

module.exports = { authenticate, verifyToken };

2. Use Middleware in Routes​

// app.js
const express = require('express');
const { authenticate } = require('./middleware/auth');

const app = express();
app.use(express.json());

// Public endpoint (no auth required)
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});

// Protected endpoint (requires auth)
app.get('/api/protected', authenticate, (req, res) => {
res.json({
message: 'Access granted',
user: {
issuer: req.user.iss,
subject: req.user.sub,
repository: req.user.repository // GitHub Actions
}
});
});

// Protected with authorization
app.post('/api/deploy', authenticate, (req, res) => {
// Check authorization based on claims
if (req.user.ref !== 'refs/heads/main') {
return res.status(403).json({ error: 'Only main branch can deploy' });
}

if (req.user.repository_owner !== 'my-org') {
return res.status(403).json({ error: 'Unauthorized organization' });
}

// Proceed with deployment
res.json({ message: 'Deployment started' });
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

Advanced Features​

Multi-Issuer Support​

Handle tokens from multiple identity providers:

// config/issuers.js
module.exports = {
'https://token.actions.githubusercontent.com': {
jwksUri: 'https://token.actions.githubusercontent.com/.well-known/jwks',
audience: 'https://api.yourservice.com',
name: 'GitHub Actions'
},
'https://gitlab.com': {
jwksUri: 'https://gitlab.com/oauth/discovery/keys',
audience: 'https://api.yourservice.com',
name: 'GitLab CI'
},
'https://kubernetes.default.svc.cluster.local': {
jwksUri: 'https://kubernetes.default.svc.cluster.local/openid/v1/jwks',
audience: 'https://api.yourservice.com',
name: 'Kubernetes'
}
};

Authorization Middleware​

Create separate middleware for fine-grained authorization:

// middleware/authorize.js

// Check if user can access specific repository
function requireRepository(allowedRepos) {
return (req, res, next) => {
const repository = req.user.repository || req.user.project_path;

if (!repository) {
return res.status(403).json({ error: 'No repository in token' });
}

if (!allowedRepos.includes(repository)) {
return res.status(403).json({ error: 'Unauthorized repository' });
}

next();
};
}

// Check if user can deploy to environment
function requireEnvironment(env) {
return (req, res, next) => {
// GitHub Actions: check ref
if (req.user.ref) {
const isMainBranch = req.user.ref === 'refs/heads/main';
if (env === 'production' && !isMainBranch) {
return res.status(403).json({ error: 'Production deploys require main branch' });
}
}

// GitLab CI: check environment
if (req.user.environment && req.user.environment !== env) {
return res.status(403).json({ error: 'Environment mismatch' });
}

next();
};
}

// Check if workflow is authorized
function requireWorkflow(allowedWorkflows) {
return (req, res, next) => {
if (!req.user.workflow) {
return res.status(403).json({ error: 'No workflow in token' });
}

if (!allowedWorkflows.includes(req.user.workflow)) {
return res.status(403).json({ error: 'Unauthorized workflow' });
}

next();
};
}

module.exports = {
requireRepository,
requireEnvironment,
requireWorkflow
};

Using Authorization Middleware​

const { authenticate } = require('./middleware/auth');
const {
requireRepository,
requireEnvironment,
requireWorkflow
} = require('./middleware/authorize');

// Deploy to production
app.post('/api/deploy/production',
authenticate,
requireRepository(['my-org/my-repo']),
requireEnvironment('production'),
requireWorkflow(['deploy']),
(req, res) => {
res.json({ message: 'Production deployment started' });
}
);

// Deploy to staging
app.post('/api/deploy/staging',
authenticate,
requireRepository(['my-org/my-repo', 'my-org/other-repo']),
requireEnvironment('staging'),
(req, res) => {
res.json({ message: 'Staging deployment started' });
}
);

Error Handling​

Centralized Error Handler​

// middleware/errorHandler.js

function errorHandler(err, req, res, next) {
// Log error details securely
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
path: req.path,
method: req.method,
ip: req.ip
});

// Authentication errors
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}

if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}

// Authorization errors
if (err.message.includes('Unauthorized')) {
return res.status(403).json({ error: err.message });
}

// Generic error
res.status(500).json({
error: process.env.NODE_ENV === 'development'
? err.message
: 'Internal server error'
});
}

module.exports = errorHandler;

Use Error Handler​

const errorHandler = require('./middleware/errorHandler');

// Routes...

// Add error handler last
app.use(errorHandler);

Testing​

Unit Tests​

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

describe('Authentication Middleware', () => {
it('should verify valid GitHub Actions token', async () => {
const token = process.env.TEST_GITHUB_TOKEN;
const claims = await verifyToken(token);

expect(claims.iss).toBe('https://token.actions.githubusercontent.com');
expect(claims.repository).toBe('my-org/my-repo');
});

it('should reject expired tokens', async () => {
const expiredToken = 'eyJhbGci...expired';
await expect(verifyToken(expiredToken))
.rejects.toThrow('expired');
});

it('should reject tokens from unknown issuers', async () => {
const unknownToken = createTokenWithIssuer('https://evil.com');
await expect(verifyToken(unknownToken))
.rejects.toThrow('Unknown issuer');
});
});

Integration Tests​

// tests/integration.test.js
const request = require('supertest');
const app = require('../app');

describe('Protected Endpoints', () => {
const validToken = process.env.TEST_GITHUB_TOKEN;

it('should allow access with valid token', async () => {
const response = await request(app)
.get('/api/protected')
.set('Authorization', `Bearer ${validToken}`);

expect(response.status).toBe(200);
expect(response.body).toHaveProperty('user');
});

it('should deny access without token', async () => {
const response = await request(app)
.get('/api/protected');

expect(response.status).toBe(401);
});

it('should enforce authorization rules', async () => {
const response = await request(app)
.post('/api/deploy/production')
.set('Authorization', `Bearer ${validToken}`);

// Depends on token claims
expect([200, 403]).toContain(response.status);
});
});

Configuration​

Environment Variables​

# .env
NODE_ENV=production
PORT=3000
EXPECTED_AUDIENCE=https://api.yourservice.com
LOG_LEVEL=info

# Optional: Override default issuers
GITHUB_JWKS_URI=https://token.actions.githubusercontent.com/.well-known/jwks
GITLAB_JWKS_URI=https://gitlab.com/oauth/discovery/keys

Load Configuration​

// config/index.js
require('dotenv').config();

module.exports = {
port: process.env.PORT || 3000,
nodeEnv: process.env.NODE_ENV || 'development',
expectedAudience: process.env.EXPECTED_AUDIENCE,
logLevel: process.env.LOG_LEVEL || 'info',

// Validate required config
validate() {
if (!this.expectedAudience) {
throw new Error('EXPECTED_AUDIENCE must be set');
}
}
};

Performance Optimization​

JWKS Caching Strategy​

const NodeCache = require('node-cache');
const jwksCache = new NodeCache({
stdTTL: 600, // 10 minutes
checkperiod: 120
});

// Override JWKS client with custom cache
function createJWKSClient(issuerConfig) {
return jwksClient({
jwksUri: issuerConfig.jwksUri,
cache: true,
cacheMaxAge: 600000,
getKeysInterceptor: (cb) => {
const cached = jwksCache.get(issuerConfig.jwksUri);
if (cached) {
return cb(null, cached);
}
// Cache miss - fetch and cache
cb();
}
});
}

Connection Pooling​

const https = require('https');

// Create agent with connection pooling
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 50,
maxFreeSockets: 10,
timeout: 60000
});

// Use in JWKS client
const client = jwksClient({
jwksUri: 'https://token.actions.githubusercontent.com/.well-known/jwks',
requestAgent: httpsAgent
});

Security Considerations​

  1. Always use HTTPS: Ensure your API only accepts HTTPS requests
  2. Validate algorithm: Only allow RS256 in JWT verification
  3. Rate limiting: Implement rate limiting on authentication endpoints
  4. CORS configuration: Properly configure CORS for your frontend
  5. Logging: Log authentication failures for security monitoring
  6. Token size limits: Reject excessively large tokens (>8KB)

Production Deployment​

Docker Example​

# Dockerfile
FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

USER node

CMD ["node", "app.js"]

Kubernetes Deployment​

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
containers:
- name: api-server
image: your-registry/api-server:latest
ports:
- containerPort: 3000
env:
- name: EXPECTED_AUDIENCE
value: "https://api.yourservice.com"
- name: NODE_ENV
value: "production"
resources:
limits:
memory: "256Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 30

Complete Example​

See the example repository for a complete working implementation.

Next Steps​