Middleware
Middleware sits between an incoming request and your handler. Each middleware decides whether to pass control deeper (await next()), short-circuit with its own response, or transform the response on the way out.
Writing middleware
The simplest middleware is a plain async function:
import type { MiddlewareHandler } from '@rudderjs/contracts'
export const requestId: MiddlewareHandler = async (req, res, next) => {
const id = req.headers['x-request-id'] ?? crypto.randomUUID()
await next()
res.header('X-Request-Id', id)
}Code before next() runs on the way in. Code after runs on the way out. Skip next() to short-circuit:
const requireAdmin: MiddlewareHandler = async (req, res, next) => {
if (req.user?.role !== 'admin') return res.status(403).json({ message: 'Forbidden' })
await next()
}For complex middleware with state, extend the Middleware class and call .toHandler() (or use fromClass() for one-shot use):
import { Middleware, fromClass } from '@rudderjs/middleware'
export class AuthMiddleware extends Middleware {
async handle(req, res, next) {
const token = req.headers['authorization']?.replace('Bearer ', '')
if (!token) return res.status(401).json({ message: 'Unauthorized' })
await next()
}
}
const handler = fromClass(AuthMiddleware) // for global registration
const oneOff = new AuthMiddleware().toHandler() // for per-route useGenerate stubs with pnpm rudder make:middleware Auth.
Middleware groups (web / api)
Routes loaded via withRouting({ web }) are tagged 'web'. Routes loaded via withRouting({ api }) are tagged 'api'. Use m.web(...) and m.api(...) to scope middleware to a single group:
import { CsrfMiddleware, RateLimit } from '@rudderjs/middleware'
.withMiddleware((m) => {
m.use(RateLimit.perMinute(60)) // every request
m.web(CsrfMiddleware()) // web routes only
m.api(RateLimit.perMinute(120)) // api routes only
})Execution order: m.use → group middleware (m.web / m.api) → per-route middleware → handler.
Several framework packages auto-install middleware into the web group when their provider boots:
@rudderjs/session— session middleware@rudderjs/auth—AuthMiddleware
API routes stay stateless by default — req.user is undefined, no session is read. For token-based API auth use @rudderjs/passport:
// routes/api.ts
import { RequireBearer, scope } from '@rudderjs/passport'
Route.get('/api/posts', handler, [RequireBearer(), scope('read')])Packages that need to install group middleware from their own provider call appendToGroup instead of router.use():
import { appendToGroup } from '@rudderjs/core'
class MyProvider extends ServiceProvider {
async boot() {
appendToGroup('web', myWebOnlyMiddleware)
}
}Per-route middleware
Pass middleware as the third argument. Use this for auth guards on specific routes, tighter rate limits, or anything that only applies to one or two endpoints:
import { RequireAuth } from '@rudderjs/auth'
import { RateLimit } from '@rudderjs/middleware'
Route.post('/posts', handler, [RequireAuth()])
Route.post('/auth/sign-in', handler, [
RateLimit.perMinute(5).message('Too many login attempts.'),
])Built-in middleware
RateLimit
Cache-backed rate limiter. Returns a MiddlewareHandler directly — no .toHandler() needed.
m.use(RateLimit.perMinute(60))
Route.post('/auth/sign-in', handler, [
RateLimit.perMinute(5).message('Too many login attempts.'),
])
// Custom key
RateLimit.perMinute(60).by((req) => req.user?.id ?? req.ip)Requires a registered cache adapter. Falls open if none is configured.
CsrfMiddleware
Double-submit-cookie CSRF protection. Apply to web routes only — not API routes.
// Server side
import { CsrfMiddleware } from '@rudderjs/middleware'
Route.post('/contact', handler, [CsrfMiddleware()])
// Exclude paths
CsrfMiddleware({ exclude: ['/api/*'] })Read the token client-side from the cookie. Import from @rudderjs/middleware/client, not the main barrel — the barrel pulls in @rudderjs/cache and a top-level node:crypto import, which Vite externalises and crashes in the browser at module-evaluation time:
// Client side
import { getCsrfToken } from '@rudderjs/middleware/client'
fetch('/contact', {
method: 'POST',
headers: { 'X-CSRF-Token': getCsrfToken() },
})CorsMiddleware
import { CorsMiddleware } from '@rudderjs/middleware'
m.use(new CorsMiddleware({
origin: ['https://app.example.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
headers: ['Content-Type', 'Authorization'],
}).toHandler())Defaults: origin: '*', methods ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], headers ['Content-Type', 'Authorization'].
When origin is an array (an allowlist), the request's Origin is reflected only when it matches; a non-matching request gets no Access-Control-Allow-Origin header (the browser blocks it), and Vary: Origin is set so shared caches don't cross-serve the header. A CORS preflight (an OPTIONS carrying Access-Control-Request-Method) is answered with 204 and not passed to your route. Set maxAge (seconds) to emit Access-Control-Max-Age and let browsers cache the preflight.
LoggerMiddleware
Logs METHOD path — Nms to the console after each request:
import { LoggerMiddleware, fromClass } from '@rudderjs/middleware'
m.use(fromClass(LoggerMiddleware))
// → [Rudder] GET /api/users — 12msThrottleMiddleware
In-memory rate limiter for single-process deployments. Skips static assets and Vite internals automatically. For multi-process, use RateLimit with a Redis cache adapter instead.
m.use(fromClass(ThrottleMiddleware)) // 60 rpm default
m.use(new ThrottleMiddleware(100, 60_000).toHandler()) // 100 per minuteErrors and pipelines
Errors thrown deeper propagate up the chain — wrap next() in a try/catch to handle downstream errors locally, or let them bubble to the framework's exception handler (Error Handling).
Pipeline composes middleware outside the router for use in jobs, scheduled tasks, or one-off scripts:
import { Pipeline } from '@rudderjs/middleware'
await new Pipeline([requestId, authMiddleware]).run(req, res, async () => {
// final handler runs after every middleware
})Pitfalls
req.userundefined on API routes. Expected —AuthMiddlewareruns only on thewebgroup. For API auth, useRequireBearer()from@rudderjs/passport.No session in contexton API routes. Don't addsessionMiddlewaretom.use(...)(global) — it's auto-installed onwebbySessionProvider.boot(). For session on a specific API route, addSessionMiddleware()per-route.RateLimitnot working. Requires@rudderjs/cacheregistered before middleware runs.- Custom
.by()key broken. Readreq.ip, not raw headers — the server adapter normalizes the IP for you.