CORS

@tekir/cors is a zero-dependency CORS middleware. Configure allowed origins as a boolean, string, array, or function and let the middleware handle preflight responses automatically.

Overview

The package exports a single cors(config?) factory that returns a standard tekir middleware function. When an incoming request carries an Origin header, the middleware decides whether that origin is allowed, sets the appropriate Access-Control-* response headers, and, for OPTIONS preflight requests, short-circuits with an HTTP 204 response before the route handler runs.

import { cors } from '@tekir/cors'

Configuration

Pass a CorsConfig object to cors(). Every key is optional, omit any key to use its default. The recommended pattern is to keep the config in config/cors.ts and pass it via config('cors') in start/kernel.ts:

config/cors.ts
import env from '#env'
import type { CorsConfig } from '@tekir/cors'

export default {
  // With credentials enabled an explicit allowlist is required:
  // 'origin: true' would be rejected at construction time.
  origin: env.NODE_ENV === 'production'
    ? ['https://app.example.com']
    : ['http://localhost:3000'],
  credentials: true,
  exposeHeaders: ['X-Total-Count'],
  maxAge: 3600
} satisfies CorsConfig
start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { cors } from '@tekir/cors'

export default function({ router, config }: TekirApp) {
  router.useGlobal([cors(config('cors'))])
}

Registering the Middleware

Apply cors() globally via router.useGlobal() so that every request receives the CORS headers, even requests that don't match any route. You can also scope it to a route group with .use():

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { cors } from '@tekir/cors'

export default function({ router }: TekirApp) {
  // Apply globally: all routes respond with CORS headers
  router.useGlobal([
    cors({
      origin: ['https://app.example.com', 'https://admin.example.com'],
      credentials: true
    })
  ])
}
// Apply only to a subset of routes
router.group(() => {
  router.get('/posts', PostController.index)
  router.post('/posts', PostController.store)
}).prefix('/api').use(cors({ origin: true }))

Origin Options

The origin config key controls which origins are allowed. Four formats are supported.

Boolean

true reflects the incoming Origin header back, effectively allowing any origin. false blocks all cross-origin requests by never setting Access-Control-Allow-Origin. Note that origin: true cannot be used with credentials: true: that combination requires an explicit allowlist and is otherwise rejected at construction time:

import { cors } from '@tekir/cors'

// true: reflect the incoming Origin header back (or '*' if no Origin is present)
// This effectively allows any origin.
router.useGlobal([cors({ origin: true })])

// false: block all cross-origin requests
router.useGlobal([cors({ origin: false })])

String

A fixed string is set verbatim as Access-Control-Allow-Origin. Requests from any other origin will not receive the header and the browser will block them:

import { cors } from '@tekir/cors'

// Exact string match: only requests from this origin are allowed
router.useGlobal([cors({ origin: 'https://app.example.com' })])

Array

Pass an array of allowed origin strings. The middleware compares the incoming Origin header against the list using strict equality. If there is a match, that origin is reflected back; otherwise the request proceeds without CORS headers:

import { cors } from '@tekir/cors'

// Array of allowed origins: the incoming Origin is checked against the list.
// If it matches, that origin is reflected back; otherwise the request proceeds
// without CORS headers (and the browser will block it).
router.useGlobal([
  cors({
    origin: [
      'https://app.example.com',
      'https://admin.example.com',
      'http://localhost:3000'  // useful for local development
    ]
  })
])

Function

For dynamic allow-lists pass a synchronous predicate. It receives the incoming origin string and returns true to allow or false to block. Useful for wildcard subdomain matching or database-driven allow-lists:

import { cors } from '@tekir/cors'

// Function: receives the incoming Origin string, returns true/false.
// Use this for dynamic allow-lists (e.g. tenant subdomains).
router.useGlobal([
  cors({
    origin: (origin) => {
      // Allow any subdomain of example.com
      return origin.endsWith('.example.com')
    }
  })
])

// Async functions are not supported here: compute any async data before
// creating the middleware and close over the result.
const allowedOrigins = new Set(await db.query('SELECT origin FROM cors_allowlist'))

router.useGlobal([
  cors({
    origin: (origin) => allowedOrigins.has(origin)
  })
])

Preflight Handling

Browsers send an OPTIONS preflight request before any cross-origin request that uses a non-simple method or header. The cors middleware intercepts these requests automatically and responds with HTTP 204 containing the negotiated Access-Control-* headers. You do not need to register an OPTIONS route.

Preflights work for every registered path, including ones that only declare POST, PUT, or DELETE handlers. The global middleware chain runs on the OPTIONS request whether or not the route author wrote an explicit OPTIONS handler.

On the actual response (the real POST, GET, or streaming endpoint), the middleware injects the negotiated Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Access-Control-Expose-Headers, and Vary: Origin headers directly onto whatever the handler returned. Plain objects, raw Response instances, and streaming bodies (SSE, file downloads) are all handled.

Customize the advertised methods and headers:

// Preflight (OPTIONS) requests are handled automatically.
// The middleware responds with HTTP 204 and the appropriate CORS headers
// before the route handler runs: no route registration for OPTIONS is needed.

// Customize which methods are advertised in the preflight response:
router.useGlobal([
  cors({
    origin: 'https://app.example.com',
    methods: ['GET', 'POST', 'PUT', 'DELETE']  // default includes HEAD and PATCH too
  })
])

// Customize which request headers are allowed:
router.useGlobal([
  cors({
    origin: 'https://app.example.com',
    headers: ['Content-Type', 'Authorization', 'X-Custom-Header'],
    // headers: true (default): reflect whatever the browser requests via
    //   Access-Control-Request-Headers
  })
])

The preflight response is cached by the browser for maxAge seconds (default 86400, 24 hours). Reduce this value during development to avoid stale preflight caches.

Credentials & Exposed Headers

Set credentials: true to allow the browser to include cookies and Authorization headers in cross-origin requests. Credentials mode requires an explicit origin allowlist: combining credentials: true with origin: true is rejected, and cors() throws at construction time so the misconfiguration surfaces at boot rather than silently granting credentialed access to every origin. Pass a string, array, or function for origin instead:

import { cors } from '@tekir/cors'

// Allow cookies and Authorization headers to be sent cross-origin.
// Note: 'origin: true' cannot be combined with credentials: true.
// cors() throws at construction time: use an explicit origin allowlist
// (string, array, or function) instead.
router.useGlobal([
  cors({
    origin: 'https://app.example.com',
    credentials: true
  })
])

// Expose response headers to the browser's JavaScript
router.useGlobal([
  cors({
    origin: 'https://app.example.com',
    credentials: true,
    exposeHeaders: ['X-Total-Count', 'X-Request-Id']
  })
])

Config Reference

Full CorsConfig interface with all defaults:

import type { CorsConfig } from '@tekir/cors'

const config: CorsConfig = {
  enabled: true,          // set false to disable entirely (default: true)

  origin: true,           // boolean | string | string[] | ((origin: string) => boolean)
                          // default: true

  methods: [              // HTTP methods advertised in preflight
    'GET', 'HEAD',        // default includes all common methods
    'POST', 'PUT',
    'PATCH', 'DELETE'
  ],

  headers: true,          // true = reflect Access-Control-Request-Headers
                          // string[] = explicit list
                          // default: true

  exposeHeaders: [],      // headers the browser JS may access (default: [])

  credentials: false,     // Access-Control-Allow-Credentials (default: false)

  maxAge: 86400          // preflight cache in seconds (default: 86400 = 24 h)
}