Skip to main content

Add authentication to your Express app

Open in Cursor

This guide walks you through protecting an Express API with LumoAuth using the @lumoauth/sdk TypeScript SDK. You'll validate access tokens, check permissions, and optionally add a server-side login flow.


Before you start

You need:

  • A LumoAuth account with a configured tenant (sign up)
  • A registered OAuth application with Client ID and Client Secret
  • Node.js 18+

From your tenant portal, note your Tenant Slug, Client ID, and Client Secret.


Install dependencies

npm install @lumoauth/sdk express
npm install -D typescript @types/express tsx

Initialize the client

src/lumo.ts
import { LumoAuth } from '@lumoauth/sdk';

export const lumo = new LumoAuth({
baseUrl: process.env.LUMOAUTH_URL || 'https://app.lumoauth.dev',
tenantSlug: process.env.LUMOAUTH_TENANT || 'acme-corp',
clientId: process.env.LUMOAUTH_CLIENT_ID || 'your-client-id',
});

Create auth middleware

Validate the Bearer token on incoming requests and attach the user to the request object.

src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import { lumo } from '../lumo';

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}

try {
const token = header.slice(7);
const user = await lumo.auth.getUserInfo(token);
(req as any).user = user;
(req as any).token = token;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}

Create permission middleware

Gate routes by RBAC permission using the LumoAuth permissions API.

src/middleware/permissions.ts
import type { Request, Response, NextFunction } from 'express';
import { lumo } from '../lumo';

export function requirePermission(permission: string) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await lumo.permissions.checkDetailed(permission);
if (!result.allowed) {
return res.status(403).json({ error: 'Forbidden', permission });
}
next();
} catch {
res.status(403).json({ error: 'Permission check failed' });
}
};
}

Set up routes

src/index.ts
import express from 'express';
import { requireAuth } from './middleware/auth';
import { requirePermission } from './middleware/permissions';

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

// Public route
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});

// Authenticated route
app.get('/api/profile', requireAuth, (req, res) => {
res.json((req as any).user);
});

// Permission-protected route
app.get(
'/api/admin/users',
requireAuth,
requirePermission('users.list'),
(req, res) => {
res.json({ users: [] });
}
);

app.delete(
'/api/admin/users/:id',
requireAuth,
requirePermission('users.delete'),
(req, res) => {
res.json({ deleted: req.params.id });
}
);

const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Server running on port ${port}`));

Add Zanzibar checks (optional)

For relationship-based access control, use the Zanzibar module:

src/routes/documents.ts
import { Router } from 'express';
import { lumo } from '../lumo';
import { requireAuth } from '../middleware/auth';

const router = Router();

router.get('/api/documents/:id', requireAuth, async (req, res) => {
const canView = await lumo.zanzibar.check({
object: `document:${req.params.id}`,
relation: 'viewer',
subject: `user:${(req as any).user.sub}`,
});

if (!canView) {
return res.status(403).json({ error: 'Not authorized to view this document' });
}

res.json({ id: req.params.id, content: '...' });
});

export default router;

Add a server-side login flow (optional)

If your Express app serves HTML pages, you can initiate PKCE login from the server:

src/routes/auth.ts
import { Router } from 'express';
import { lumo } from '../lumo';

const router = Router();

router.get('/auth/login', (req, res) => {
const { url, codeVerifier, state } = lumo.auth.buildAuthorizationUrl({
redirectUri: 'http://localhost:3000/auth/callback',
scope: 'openid profile email',
});

// Store in session (requires express-session)
(req as any).session.codeVerifier = codeVerifier;
(req as any).session.state = state;
res.redirect(url);
});

router.get('/auth/callback', async (req, res) => {
const tokens = await lumo.auth.exchangeCodeForTokens({
code: req.query.code as string,
codeVerifier: (req as any).session.codeVerifier,
redirectUri: 'http://localhost:3000/auth/callback',
});

(req as any).session.accessToken = tokens.access_token;
res.redirect('/dashboard');
});

export default router;

Run the server

npx tsx src/index.ts

Test with curl:

# Health check
curl http://localhost:3000/api/health

# Authenticated request
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
http://localhost:3000/api/profile

# Permission-protected request
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
http://localhost:3000/api/admin/users

What's next?