Skip to content

Security Middleware

OneBun provides three built-in security middleware components that can be enabled via ApplicationOptions shorthand properties or applied manually via the middleware array.

Quick Setup

typescript
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';

const app = new OneBunApplication(AppModule, {
  cors: { origin: 'https://my-frontend.example.com', credentials: true },
  rateLimit: { windowMs: 60_000, max: 100 },
  security: true,  // use all defaults
});

await app.start();

Auto-ordering when all three are active:

Request → CorsMiddleware → RateLimitMiddleware → [your middleware] → SecurityHeadersMiddleware → Handler

CorsMiddleware

Adds Access-Control-* headers to every response that comes from a matched route, including error responses.

Preflight

A browser preflight is answered before routing. You do not need an @Options() route on the paths your frontend calls — a path that declares only @Post() answers its preflight correctly, and so does a path that does not exist.

Five things follow, and they are worth stating exactly:

  1. No @Options() route is required. Previously one was: without it the OPTIONS never entered the middleware chain and came back a bare 404 with no CORS headers, so the browser blocked the POST it was preflighting.
  2. A declared @Options() route is never shadowed by the short-circuit. Bun's router runs first, so a matched path never reaches the fallback where the short-circuit lives.
  3. A non-preflight OPTIONS reaches your application. The split is the Access-Control-Request-Method header, which the Fetch spec requires on every real preflight. A curl -X OPTIONS is API discovery, not CORS, and it now reaches your @Options() handler — or an honest 404 where you declared none.
  4. A real preflight is answered by CORS even where an @Options() route exists. A preflight response that does not carry the grant is a blocked request whatever its status, so the CORS layer answers it. Use preflightContinue: true to take that over.
  5. preflightContinue: true opts out entirely. The short-circuit does not fire and today's behaviour is preserved, because the option exists so that a downstream handler produces the response.

A disallowed origin still gets a well-formed 204 — without Access-Control-Allow-Origin. The browser then blocks the request, which is the correct outcome and its decision to make.

Via ApplicationOptions.cors

typescript
const app = new OneBunApplication(AppModule, {
  cors: {
    origin: 'https://my-frontend.example.com',
    credentials: true,
    methods: ['GET', 'POST', 'PUT', 'DELETE'],
    allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
    exposedHeaders: ['X-Request-Id'],
    maxAge: 3600,
  },
});

Pass cors: true to allow all origins with default settings.

Via middleware array (manual configuration)

typescript
import { CorsMiddleware } from '@onebun/core';

const app = new OneBunApplication(AppModule, {
  middleware: [
    CorsMiddleware.configure({
      origin: /\.example\.com$/,  // RegExp origin matching
    }),
  ],
});

Preflight is answered before routing on this spelling too — the short-circuit finds the middleware by prototype, and CorsMiddleware.configure() returns a subclass. Prefer ApplicationOptions.cors: it places CORS first in the chain for you, which is where it has to be.

CorsOptions

PropertyTypeDefaultDescription
originstring | RegExp | Array<...> | ((origin) => boolean)'*'Allowed origin(s)
methodsstring[]['GET','HEAD','PUT','PATCH','POST','DELETE','OPTIONS']Allowed methods
allowedHeadersstring[]['Content-Type', 'Authorization']Allowed request headers
exposedHeadersstring[]Headers exposed to the browser
credentialsbooleanfalseAllow cookies / credentials
maxAgenumber86400Preflight cache duration (seconds)
preflightContinuebooleanfalsePass OPTIONS to next handler

Origin variants

typescript
// Any origin (default)
cors: true

// Exact string
cors: { origin: 'https://example.com' }

// RegExp
cors: { origin: /\.example\.com$/ }

// Array
cors: { origin: ['https://app1.com', 'https://app2.com', /\.dev$/] }

// Function predicate
cors: { origin: (o) => o.startsWith('https://trusted') }

RateLimitMiddleware

Limits the number of requests per time window per client. Supports in-memory and Redis backends.

The client is identified by the transport peer address — the address the TCP connection actually came from, read via server.requestIP(). It is not taken from a header, so a caller cannot move itself into a fresh bucket by setting one. See Client identification and trustProxy for running behind a load balancer.

Via ApplicationOptions.rateLimit

typescript
const app = new OneBunApplication(AppModule, {
  rateLimit: {
    windowMs: 15 * 60 * 1000,  // 15 minutes
    max: 200,                   // 200 requests per window
  },
});

Pass rateLimit: true for defaults (100 requests / 60 seconds, in-memory, keyed on the transport peer address).

Client identification and trustProxy

ApplicationOptions.trustProxy decides whether the proxy headers a caller sends may override the transport peer. It is false by default and lives on the application, not on the rate limiter, because it settles one question — "who called" — for everything that asks: the default rate-limit key and the remoteAddr field on HTTP spans.

trustProxyClient address isUse when
false (default)the transport peer from server.requestIP()the app is reachable directly, or you are unsure
truefirst entry of x-forwarded-for, else cf-connecting-ip, else x-real-ip, else the peerevery request arrives through a proxy that overwrites those headers
typescript
// Direct exposure — headers are ignored, the peer is the bucket
const app = new OneBunApplication(AppModule, {
  rateLimit: { windowMs: 60_000, max: 100 },
});

// Behind a load balancer that sets x-forwarded-for
const app = new OneBunApplication(AppModule, {
  trustProxy: true,
  rateLimit: { windowMs: 60_000, max: 100 },
});

Do not enable trustProxy on a directly reachable app

x-forwarded-for is just a request header. If clients can reach the application without passing through a proxy that overwrites it, trustProxy: true lets any caller pick its own rate-limit bucket — a fresh header value per request means no effective limit at all. Conversely, leaving it off behind a proxy makes every request appear to come from the proxy, so all callers share one bucket. Match the setting to your deployment.

Reading the client address yourself

getClientAddress(req) returns the same address the framework uses, honouring trustProxy. getPeerAddress(req) always returns the transport peer, ignoring headers entirely.

typescript
import { getClientAddress, RateLimitMiddleware } from '@onebun/core';

RateLimitMiddleware.configure({
  // Authenticated callers get their own bucket; anonymous ones fall back to their address
  keyGenerator: (req) => req.headers.get('x-api-key') ?? getClientAddress(req) ?? 'unknown',
});

Both return undefined for a Request that never went through a OneBun server (a hand-constructed one in a unit test, for example). The default key generator falls back to 'unknown' in that case; on a served request the peer is always known.

Redis-backed (multi-instance)

SharedRedisProvider must be configured before the first getClient() call — there is no auto-configuration and no REDIS_URL fallback, so calling it unconfigured throws SharedRedisProvider not configured.

typescript
import { RateLimitMiddleware, RedisRateLimitStore } from '@onebun/core';
import { SharedRedisProvider } from '@onebun/core';

SharedRedisProvider.configure({ url: 'redis://localhost:6379' });

const redis = await SharedRedisProvider.getClient();

const app = new OneBunApplication(AppModule, {
  middleware: [
    RateLimitMiddleware.configure({
      windowMs: 60_000,
      max: 100,
      store: new RedisRateLimitStore(redis),
    }),
  ],
});

getClient() takes a lease on the shared connection — call await SharedRedisProvider.release() on shutdown to give it back.

Custom key generator

typescript
RateLimitMiddleware.configure({
  max: 50,
  windowMs: 60_000,
  keyGenerator: (req) => req.headers.get('x-api-key') ?? 'anon',
})

RateLimitOptions

PropertyTypeDefaultDescription
windowMsnumber60_000Time window in ms
maxnumber100Max requests per window
keyGenerator(req) => stringgetClientAddress(req) ?? 'unknown'Key for grouping requests
messagestring'Too Many Requests'Error message when limit exceeded
standardHeadersbooleantrueAdd RateLimit-* headers
legacyHeadersbooleanfalseAdd the legacy X-RateLimit-* headers, plus Retry-After on the 429
storeRateLimitStoreMemoryRateLimitStoreStorage backend

Rate limit response (HTTP 429)

json
{
  "success": false,
  "error": "Too Many Requests",
  "code": 429,
  "details": {}
}

Response headers (when standardHeaders: true):

  • RateLimit-Limit: 100
  • RateLimit-Remaining: 0
  • RateLimit-Reset: 42 (seconds until window resets)

Retry-After (same value as RateLimit-Reset) is sent only when legacyHeaders: true, and only on the 429 — never on a successful response. It does not depend on standardHeaders.


SecurityHeadersMiddleware

Sets security-related HTTP response headers on every response — analogous to helmet.

Via ApplicationOptions.security

typescript
// All defaults
const app = new OneBunApplication(AppModule, { security: true });

// Custom configuration
const app = new OneBunApplication(AppModule, {
  security: {
    contentSecurityPolicy: "default-src 'self'; img-src *",
    strictTransportSecurity: false,  // disable HSTS in development
  },
});

Default headers set

HeaderDefault value
Content-Security-Policydefault-src 'self'
Cross-Origin-Opener-Policysame-origin
Cross-Origin-Resource-Policysame-origin
Origin-Agent-Cluster?1
Referrer-Policyno-referrer
Strict-Transport-Securitymax-age=15552000; includeSubDomains
X-Content-Type-Optionsnosniff
X-DNS-Prefetch-Controloff
X-Download-Optionsnoopen
X-Frame-OptionsSAMEORIGIN
X-Permitted-Cross-Domain-Policiesnone
X-XSS-Protection0 (disabled — use CSP instead)

SecurityHeadersOptions

Each property accepts a string (custom value) or false (disable the header entirely).

typescript
security: {
  contentSecurityPolicy: "default-src 'self'; connect-src 'self' https://api.example.com",
  xFrameOptions: 'DENY',
  strictTransportSecurity: false,  // disable in local dev
}

Implementing a Custom Store

You can plug in any storage backend by implementing the RateLimitStore interface:

typescript
import type { RateLimitStore } from '@onebun/core';

class MyCustomStore implements RateLimitStore {
  async increment(
    key: string,
    windowMs: number,
  ): Promise<{ count: number; resetAt: number }> {
    // ...custom logic...
    return { count: 1, resetAt: Date.now() + windowMs };
  }
}

const app = new OneBunApplication(AppModule, {
  middleware: [
    RateLimitMiddleware.configure({ store: new MyCustomStore() }),
  ],
});

Released under the MPL-2.0 License.