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. The preflight is answered with its CORS headers whether or not the path declares one, so the browser gets the grant it needs before it sends 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 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 option exists so that a downstream handler produces the response, so the short-circuit does not fire: the preflight is routed like any other request, and CorsMiddleware adds its headers to whatever the handler answers.

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();
// ...and on shutdown, give the hold back — nothing else will:
// await SharedRedisProvider.release();

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

getClient() takes a hold on the shared connection — call await SharedRedisProvider.release() on shutdown to give it back. This is not optional: an application's stop() releases nothing, so a hold nobody gives back keeps the socket open and the process alive. The shutdown log names what still holds it.

The counter is an integer Redis itself increments, inside a Lua script that also arms the window's expiry on the first request and reads the remaining TTL back. The script is what makes the store worth choosing: a read-modify-write issued from the application leaves a window in which another replica increments the same key, both write the same value, and one request goes uncounted — an undercount that lets more through than max, silently, in exactly the multi-instance deployment this store exists for.

Keys live under rl: by default. Two applications sharing one Redis share that namespace and therefore share buckets — one application's traffic spends the other's budget. Give each its own:

typescript
new RedisRateLimitStore(redis, { keyPrefix: 'intake:rl:' })

RedisRateLimitStoreOptions ​

PropertyTypeDefaultDescription
keyPrefixstring'rl:'Namespace for this store's counter keys

There is one shared connection per process and therefore one configuration: a second configure() with a different target throws rather than being quietly ignored. See Shared Redis Connection.

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.