Skip to content

Express

This guide walks through setting up authentication in an Express application using ideal-auth. By the end, you will have working login, registration, logout, route protection via middleware, and access to the current user in your route handlers.

  1. Install ideal-auth and cookie-parser

    Terminal window
    bun add ideal-auth cookie-parser
    bun add -D @types/cookie-parser @types/express

    cookie-parser is required for reading cookies from incoming requests. Express does not parse cookies by default.

  2. Set the session secret

    Add a secret to your .env file. It must be at least 32 characters.

    .env
    IDEAL_AUTH_SECRET="at-least-32-characters-long-secret-here"

    Generate a strong secret:

    Terminal window
    bunx ideal-auth secret

Express uses req.cookies (from cookie-parser) to read cookies and res.cookie() / res.clearCookie() to write and delete them. Since the bridge needs access to both req and res, create it per-request.

src/lib/cookies.ts
import type { Request, Response } from 'express';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(req: Request, res: Response): CookieBridge {
return {
get(name: string) {
return req.cookies[name];
},
set(name, value, options) {
res.cookie(name, value, {
httpOnly: options.httpOnly,
secure: options.secure,
sameSite: options.sameSite,
path: options.path ?? '/',
...(options.maxAge !== undefined && { maxAge: options.maxAge * 1000 }), // Express uses milliseconds
});
},
delete(name) {
res.clearCookie(name, { path: '/' });
},
};
}

Create an auth() factory that accepts Express req and res and returns an AuthInstance.

src/lib/auth.ts
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db'; // your database client
type User = {
id: string;
email: string;
name: string;
password: string;
};
export const hash = createHash({ rounds: 12 });
export function auth(req: import('express').Request, res: import('express').Response) {
const authFactory = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(req, res),
hash,
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
async resolveUserByCredentials(credentials) {
return db.user.findUnique({
where: { email: credentials.email },
});
},
});
return authFactory();
}

src/app.ts
import express from 'express';
import cookieParser from 'cookie-parser';
import { authRouter } from './routes/auth';
import { dashboardRouter } from './routes/dashboard';
import { csrfProtection } from './middleware/csrf';
import { requireAuth } from './middleware/auth';
const app = express();
// Parse JSON bodies and cookies
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// CSRF protection for state-changing requests
app.use(csrfProtection);
// Public routes
app.use('/auth', authRouter);
// Protected routes
app.use('/dashboard', requireAuth, dashboardRouter);
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
export default app;

src/routes/auth.ts
import { Router } from 'express';
import { auth, hash } from '../lib/auth';
import { db } from '../lib/db';
export const authRouter = Router();
authRouter.post('/login', async (req, res) => {
try {
const { email, password, remember } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required.' });
}
const session = auth(req, res);
const success = await session.attempt(
{ email, password },
{ remember: remember ?? false },
);
if (!success) {
return res.status(401).json({ error: 'Invalid email or password.' });
}
return res.json({ success: true });
} catch (error) {
console.error('Login error:', error);
return res.status(500).json({ error: 'Internal server error.' });
}
});

src/routes/auth.ts
authRouter.post('/register', async (req, res) => {
try {
const { email, name, password, passwordConfirmation } = req.body;
if (!email || !name || !password) {
return res.status(400).json({ error: 'All fields are required.' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
}
if (password !== passwordConfirmation) {
return res.status(400).json({ error: 'Passwords do not match.' });
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
const user = await db.user.create({
data: {
email,
name,
password: await hash.make(password),
},
});
// Log the user in immediately after registration
const session = auth(req, res);
await session.login(user);
return res.status(201).json({ success: true });
} catch (error) {
console.error('Registration error:', error);
return res.status(500).json({ error: 'Internal server error.' });
}
});

src/routes/auth.ts
authRouter.post('/logout', async (req, res) => {
try {
const session = auth(req, res);
await session.logout();
return res.json({ success: true });
} catch (error) {
console.error('Logout error:', error);
return res.status(500).json({ error: 'Internal server error.' });
}
});

src/routes/auth.ts
authRouter.get('/me', async (req, res) => {
try {
const session = auth(req, res);
const user = await session.user();
if (!user) {
return res.json({ user: null });
}
return res.json({
user: { id: user.id, email: user.email, name: user.name },
});
} catch (error) {
console.error('User fetch error:', error);
return res.status(500).json({ error: 'Internal server error.' });
}
});

Create a reusable middleware that protects routes from unauthenticated access.

src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import { auth } from '../lib/auth';
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
try {
const session = auth(req, res);
const user = await session.user();
if (!user) {
return res.status(401).json({ error: 'Authentication required.' });
}
// Attach user to request for downstream handlers
req.user = user;
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Internal server error.' });
}
}

Extend the Express Request type to include user:

src/types/express.d.ts
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
name: string;
};
}
}
}
export {};

src/routes/dashboard.ts
import { Router } from 'express';
export const dashboardRouter = Router();
dashboardRouter.get('/', (req, res) => {
// req.user is set by the requireAuth middleware
res.json({
message: `Welcome, ${req.user!.name}`,
user: req.user,
});
});
dashboardRouter.get('/settings', (req, res) => {
res.json({
message: 'Settings page',
user: req.user,
});
});

app.get('/api/profile', requireAuth, (req, res) => {
// req.user is available from the middleware
res.json({ user: req.user });
});

Optional auth (user may or may not be logged in)

Section titled “Optional auth (user may or may not be logged in)”
src/middleware/optional-auth.ts
import type { Request, Response, NextFunction } from 'express';
import { auth } from '../lib/auth';
export async function optionalAuth(req: Request, res: Response, next: NextFunction) {
try {
const session = auth(req, res);
const user = await session.user();
if (user) {
req.user = { id: user.id, email: user.email, name: user.name };
}
next();
} catch {
next();
}
}

Use it on routes where the user might be logged in but authentication is not required:

app.get('/', optionalAuth, (req, res) => {
if (req.user) {
res.json({ message: `Hello, ${req.user.name}` });
} else {
res.json({ message: 'Hello, guest' });
}
});

Express does not include built-in CSRF protection. Implement Origin header validation as middleware.

src/middleware/csrf.ts
import type { Request, Response, NextFunction } from 'express';
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
export function csrfProtection(req: Request, res: Response, next: NextFunction) {
if (SAFE_METHODS.includes(req.method)) {
return next();
}
const origin = req.get('origin');
const host = req.get('host');
if (!origin || !host) {
return res.status(403).json({ error: 'Forbidden: missing origin header.' });
}
try {
const originHost = new URL(origin).host;
if (originHost !== host) {
return res.status(403).json({ error: 'Forbidden: origin mismatch.' });
}
} catch {
return res.status(403).json({ error: 'Forbidden: invalid origin.' });
}
next();
}

If you serve HTML forms from Express (e.g., with a template engine), you can use a CSRF token approach:

src/middleware/csrf-token.ts
import { generateToken, timingSafeEqual } from 'ideal-auth';
import type { Request, Response, NextFunction } from 'express';
const CSRF_COOKIE = '_csrf';
const CSRF_HEADER = 'x-csrf-token';
export function csrfToken(req: Request, res: Response, next: NextFunction) {
// Generate a CSRF token and set it as a readable cookie
let token = req.cookies[CSRF_COOKIE];
if (!token) {
token = generateToken(32);
res.cookie(CSRF_COOKIE, token, {
httpOnly: false, // must be readable by JavaScript
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
}
// Make token available for templates
res.locals.csrfToken = token;
next();
}
export function verifyCsrfToken(req: Request, res: Response, next: NextFunction) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const cookieToken = req.cookies[CSRF_COOKIE];
const headerToken = req.get(CSRF_HEADER) || req.body?._csrf;
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing.' });
}
if (!timingSafeEqual(cookieToken, headerToken)) {
return res.status(403).json({ error: 'CSRF token mismatch.' });
}
next();
}

src/
app.ts # Express app setup
lib/
auth.ts # Auth factory
cookies.ts # Cookie bridge
db.ts # Database client
middleware/
auth.ts # requireAuth middleware
csrf.ts # CSRF protection
optional-auth.ts # Optional auth middleware
routes/
auth.ts # Login, register, logout, me
dashboard.ts # Protected dashboard routes
types/
express.d.ts # Request type extensions

  • Session secret: Store IDEAL_AUTH_SECRET in environment variables. Use a library like dotenv to load .env files in development.
  • HTTPS: Set NODE_ENV=production in production so session cookies are automatically marked Secure. Run Express behind a reverse proxy (nginx, Caddy) that terminates TLS.
  • Trust proxy: If Express is behind a reverse proxy, set app.set('trust proxy', 1) so req.secure and X-Forwarded-* headers work correctly.
  • Cookie maxAge units: Remember that Express uses milliseconds for maxAge while ideal-auth uses seconds. The bridge in this guide handles the conversion.
  • cookie-parser: The cookie-parser middleware is required. Without it, req.cookies will be undefined.
  • Error handling: In production, avoid leaking stack traces in error responses. The try/catch blocks in the route handlers above return generic error messages.
  • Rate limiting: Express is a common target for brute-force attacks. Use ideal-auth’s createRateLimiter or a dedicated middleware like express-rate-limit to protect the login endpoint.