Skip to main content
Cross-Origin Resource Sharing (CORS) is the browser security mechanism that decides whether a page served from one origin (scheme + host + port) may read a response from another. Without the right response headers, the browser blocks the cross-origin read even though your server handled the request. The @ooneex/middleware component ships a ready-to-use CorsMiddleware that reads its policy from environment variables, validates the request’s Origin, sets the matching Access-Control-* headers, and answers preflight (OPTIONS) requests — so you turn CORS on by configuration, not code.

Installation

CorsMiddleware ships with @ooneex/middleware, which is already a dependency of @ooneex/app. If you depend on it directly, add it:
bun add @ooneex/middleware
It is constructed with AppEnv from @ooneex/app-env injected by the container, so its policy is read from the same configuration your app already loads — there is nothing else to wire up.

Why this component

  • Zero-code policy. The whole policy comes from CORS_* environment variables read through AppEnv; you register the middleware once and tune it per environment with no redeploy of code.
  • Origin-checked. Every request’s Origin is matched against your allowlist before any header is set. A disallowed origin gets no CORS headers, so the browser blocks it.
  • Preflight handled. OPTIONS requests are answered with Access-Control-Max-Age and a 204, so browsers cache the preflight and skip it on later calls.
  • Sensible defaults. Methods, headers, and a one-day max-age default to safe values; you only set what you need to change.
  • Container-native. It is a normal injectable middleware resolved from the container — register it through App’s dedicated cors slot, alongside your auth pipeline.

How it works

The middleware reads its configuration once at construction from the injected AppEnv, then applies it on every request in handler.
StageWhat happens
ConstructionAppEnv is injected; the CORS_* variables are parsed into origins, methods, headers, exposed headers, a credentials flag, and a max-age.
No OriginIf the request has no Origin header (a same-origin or non-browser call), the context is returned untouched — no CORS headers.
Origin checkThe Origin is matched against the allowlist. If it is not allowed, the context is returned untouched.
AllowedAccess-Control-Allow-Origin, -Allow-Methods, -Allow-Headers, and -Allow-Credentials are set; exposed headers are added when configured.
PreflightOn OPTIONS, Access-Control-Max-Age is set and the middleware responds 204.
When CORS_ORIGINS is * (the default), the allow-origin header is sent as the literal *. When you configure a specific allowlist, the middleware reflects the request’s origin back instead of a list — the only spec-compliant way to allow several named origins.

Configuration

Every setting is an environment variable read through AppEnv. List values are comma-separated.
VariableDefaultDescription
CORS_ORIGINS*Allowed origins. * allows any origin; otherwise a comma-separated allowlist, e.g. https://app.example.com, https://admin.example.com.
CORS_METHODSGET, HEAD, PUT, PATCH, POST, DELETEMethods sent in Access-Control-Allow-Methods.
CORS_HEADERSContent-Type, AuthorizationRequest headers allowed via Access-Control-Allow-Headers.
CORS_EXPOSED_HEADERS(none)Response headers the browser may read, sent as Access-Control-Expose-Headers. Omitted when unset.
CORS_CREDENTIALSfalseWhen true, sets Access-Control-Allow-Credentials: true so cookies and auth headers are allowed.
CORS_MAX_AGE86400Seconds a browser may cache the preflight result, sent as Access-Control-Max-Age on OPTIONS.
In an Ooneex app these are set in .env.yml under the cors key, which AppEnv maps onto the CORS_* variables when the app loads its environment:
.env.yml
# Cross-Origin Resource Sharing
cors:
  # Allow two named front-ends to call the API with credentials.
  origins: "https://app.example.com, https://admin.example.com"
  methods: "GET, POST, PUT, DELETE"
  headers: "Content-Type, Authorization"
  exposed_headers: "X-Request-Id, X-Total-Count"
  credentials: "true"
  max_age: "3600"
The same values can be supplied as plain environment variables instead — AppEnv reads whichever is present:
.env
CORS_ORIGINS=https://app.example.com, https://admin.example.com
CORS_METHODS=GET, POST, PUT, DELETE
CORS_HEADERS=Content-Type, Authorization
CORS_EXPOSED_HEADERS=X-Request-Id, X-Total-Count
CORS_CREDENTIALS=true
CORS_MAX_AGE=3600
Do not combine CORS_CREDENTIALS=true with CORS_ORIGINS=*. Browsers reject a credentialed response whose allow-origin is the wildcard *. When you need credentials, set an explicit allowlist so the middleware reflects a concrete origin instead.

Registering the middleware

App takes a dedicated cors slot in its config — pass CorsMiddleware there rather than listing it among your route middlewares:
import { App } from "@ooneex/app";
import { CorsMiddleware } from "@ooneex/middleware";
import { TerminalLogger } from "@ooneex/logger";
import { AuthMiddleware } from "./middleware";

const app = new App({
  routing: { prefix: "/api" },
  loggers: [TerminalLogger],
  middlewares: [AuthMiddleware],
  cors: CorsMiddleware,
});

await app.run();
The cors slot is wired specially. The framework appends it after your route middlewares and runs it on the catch-all (/*) handler, so CORS headers land on every response — including 404s and routes short-circuited by authentication, since the pipeline runs the whole chain without breaking. That is why CORS belongs in cors, not in middlewares: a middleware listed only in middlewares never runs on unmatched routes. With nothing configured, registering CorsMiddleware already gives you a permissive * policy — useful in development. Lock it down per environment by setting the cors.* keys in .env.yml (or the CORS_* variables).

How a request flows

The middleware only acts when the browser sends an Origin header, and only when that origin is allowed.
// Inside CorsMiddleware.handler — illustrative.
const origin = context.header.get("Origin");

// Same-origin or non-browser call: nothing to do.
if (!origin) return context;

// Origin not in the allowlist: send no CORS headers, the browser blocks the read.
if (!this.isOriginAllowed(origin)) return context;

// Allowed: reflect the origin (or "*") and advertise the policy.
context.response.header
  .setAccessControlAllowOrigin(this.origins === "*" ? "*" : origin)
  .setAccessControlAllowMethods(this.methods)
  .setAccessControlAllowHeaders(this.headers)
  .setAccessControlAllowCredentials(this.credentials);

// Preflight: cache the result and answer 204.
if (context.method === "OPTIONS") {
  context.response.header.set("Access-Control-Max-Age", String(this.maxAge));
  context.response.json({}, 204);
}

return context;
A disallowed origin is not an error response — the request still runs, but without CORS headers the browser refuses to expose the result to the page. That is by design: CORS is enforced client-side.

Preflight requests

For any “non-simple” request — a custom header, or a method beyond GET/HEAD/POST — the browser first sends an OPTIONS preflight. CorsMiddleware answers it with the allow headers plus Access-Control-Max-Age, then a 204. The browser caches that result for CORS_MAX_AGE seconds and skips the preflight on subsequent identical calls, so raising CORS_MAX_AGE reduces round-trips at the cost of slower policy changes taking effect.

Best practices

  • Pin origins in production. Use an explicit CORS_ORIGINS allowlist rather than * for any deployed environment; reserve the wildcard for local development.
  • Only enable credentials when needed. Set CORS_CREDENTIALS=true solely for cookie- or auth-header-based flows, and always pair it with a named-origin allowlist.
  • Keep the allowed headers tight. List only the request headers your API actually reads in CORS_HEADERS; a broad list weakens the intent of the policy.
  • Use the cors slot, not middlewares. Passing CorsMiddleware as cors is what makes it run on the catch-all and on rejected requests, so the browser can read errors and 404s carry the right headers.
  • Tune max-age deliberately. A longer CORS_MAX_AGE cuts preflight traffic but means origin or method changes take longer to reach already-cached browsers.
  • Middleware — the pipeline CorsMiddleware runs in.
  • JWT — token verification middleware to register after CORS.
  • Users — the user model resolved on authenticated requests.
  • Configuration — how AppEnv loads the CORS_* variables.