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 use

Generate 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/authAuthMiddleware

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 — 12ms

ThrottleMiddleware

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 minute

Errors 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.user undefined on API routes. Expected — AuthMiddleware runs only on the web group. For API auth, use RequireBearer() from @rudderjs/passport.
  • No session in context on API routes. Don't add sessionMiddleware to m.use(...) (global) — it's auto-installed on web by SessionProvider.boot(). For session on a specific API route, add SessionMiddleware() per-route.
  • RateLimit not working. Requires @rudderjs/cache registered before middleware runs.
  • Custom .by() key broken. Read req.ip, not raw headers — the server adapter normalizes the IP for you.