CorsMiddleware answers preflight OPTIONS requests and decorates
ordinary cross-origin responses with Access-Control-Allow-* headers.
You install it once in bootstrap() when a browser on a different
origin calls your API - public APIs, an SPA hosted on a different
domain, a mobile webview, or a separately-hosted docs site. Same-origin
apps (Inertia served from the same host as the backend, the Suprnova
default) don't need CORS at all. The middleware mirrors Laravel's
HandleCors and config/cors.php, but as a typed builder on
CorsConfig.
Install it globally
use Duration;
use ;
A preflight is an OPTIONS request with an Access-Control-Request-Method
header. The router has no OPTIONS routes, so a preflight never matches
a route - but Suprnova's server runs the global middleware chain on
unmatched requests (terminating in a 404), so a globally-installed
CorsMiddleware sees the preflight and short-circuits it with 204
before the 404 is ever produced. This is why CORS must be installed
globally, not per-route.
Choosing an origin policy
There is intentionally no Default for CorsConfig. A reflexively
permissive policy is a security footgun, so you must pick:
| Builder | Behavior |
|---|---|
CorsConfig::allow_origins([...]) |
Fixed allow-list. Origin is echoed back only when it exactly matches one entry. |
CorsConfig::any_origin() |
Wildcard *. With credentials enabled, the middleware echoes the specific request origin instead of * (the * + credentials combination is invalid per the Fetch spec). |
.allow_origin_patterns([...]) |
Regex patterns added on top of the literal list. Useful for dynamic subdomains. |
allow_origins
.allow_origin_patterns
Patterns are anchored automatically - ^ and $ are prepended / appended
if missing, so a partial match against a redirect URL like
https://evil.com/?u=https://app.example cannot leak through.
Invalid regex panics at config time (boot), not at request time - surface the config bug loud rather than fail-open silently.
allowed_origins_patterns (Laravel-named alias) is also available.
Scoping which paths get CORS
Laravel's cors.php config has a paths array (['api/*', 'sanctum/csrf-cookie']) that limits CORS application to specific URL
patterns. Suprnova mirrors this:
allow_origins
.paths
With no paths set, CORS runs on every request (Suprnova's default -
since the middleware is opt-in by registration). With at least one
pattern set, only matching requests get CORS treatment (both preflights
and actual-response decoration); everything else flows through
untouched.
Patterns use Laravel's Str::is semantics: * is a multi-segment
wildcard greedy across /. Leading / is normalized so "api/*" and
"/api/*" are equivalent.
"api/*" // matches /api/users, /api/users/42
"api/*/posts" // matches /api/v2/posts, /api/v1/posts
"sanctum/csrf-cookie" // exact-match literal
"*" // matches everything
Skip via predicate
For request-shape predicates that don't fit a path pattern (skip based on
a header, only run CORS in production, skip during health checks), use
skip_when:
any_origin
.skip_when
.skip_when
Mirrors Laravel's HandleCors::skipWhen(Closure) but lives on the policy
rather than as global mutable state. Multiple skip_when callbacks can
be registered; any one returning true skips CORS.
Methods, headers, exposed headers
allow_origins
.methods // default = GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD
.allow_headers // restrict; default = reflect request
.allow_any_headers // explicit "reflect whatever was requested"
.expose_headers // headers JS may read on the response
Laravel-named aliases (so cors.php users find what they expect):
allowed_methods(...)≡methods(...)allowed_headers(...)≡allow_headers(...)exposed_headers(...)≡expose_headers(...)allowed_origins_patterns(...)≡allow_origin_patterns(...)supports_credentials(...)≡allow_credentials(...)
Credentials and *
Per the Fetch spec, Access-Control-Allow-Origin: * is invalid together
with credentials - the browser rejects the response. With an explicit
origin list (allow_origins([...])) plus allow_credentials(true),
the middleware echoes the specific request Origin rather than *,
and the policy works as expected.
any_origin() + allow_credentials(true) panics at build time. The
combination is a complete bypass of origin allowlisting: any attacker
page can make credentialed cross-origin requests and read responses.
Rather than emit the wrong header at runtime, the policy constructor
fails loud so the misconfiguration never reaches a running deployment.
Use an explicit allowlist instead:
// CORRECT - explicit allowlist with credentials.
allow_origins.allow_credentials
// → on request with Origin: https://app.example
// → response: Access-Control-Allow-Origin: https://app.example
// Access-Control-Allow-Credentials: true
// REJECTED at build time - panics with a remediation message.
// CorsConfig::any_origin().allow_credentials(true)
Max-age
.max_age // typed
.max_age_secs // Laravel-style integer-seconds
Access-Control-Max-Age tells the browser how long it may cache the
preflight result. Higher = fewer preflight round-trips, slower policy
changes propagate.
What the middleware actually emits
Preflight (OPTIONS + Access-Control-Request-Method)
If the origin is allowed:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Credentials: true // when credentials enabled
Access-Control-Allow-Methods: GET, POST, ...
Access-Control-Allow-Headers: <reflected or fixed>
Access-Control-Max-Age: 600 // when set
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
If the origin is disallowed: bare 204 + Vary (no Access-Control-*).
The browser's missing-header check produces the CORS error - matching
the tower-http convention.
Actual cross-origin response
When the request has an Origin header and the origin is allowed:
Access-Control-Allow-Origin: <origin or *>
Access-Control-Allow-Credentials: true // when enabled
Access-Control-Expose-Headers: X-Total, Link // when configured
Vary: Origin // only when not "*"
A * ACAO is identical for every origin, so no Vary is needed; a
specific origin varies per-origin so shared caches must key on it.
Testing CORS handlers
CORS is browser-side enforced - the server still runs the handler even when the origin is disallowed; it just doesn't decorate the response. That's the testable behavior:
let = request_with_origin.await;
assert_eq!;
assert_eq!;
For a disallowed origin, the handler runs and the body comes back, but
the absence of Access-Control-Allow-Origin is what blocks the browser
from reading it.
Laravel parity matrix
Laravel cors.php |
Suprnova builder |
|---|---|
paths |
.paths([...]) |
allowed_methods |
.methods([...]) / .allowed_methods([...]) |
allowed_origins |
CorsConfig::allow_origins([...]) |
allowed_origins_patterns |
.allow_origin_patterns([...]) / .allowed_origins_patterns([...]) |
allowed_headers |
.allow_headers([...]) / .allowed_headers([...]) |
exposed_headers |
.expose_headers([...]) / .exposed_headers([...]) |
max_age |
.max_age(Duration) / .max_age_secs(u64) |
supports_credentials |
.allow_credentials(bool) / .supports_credentials(bool) |
HandleCors::skipWhen(closure) |
.skip_when(|req| ...) |
The middleware is registered globally rather than the Laravel-style
"automatically installed for paths" - Suprnova's middleware chain is
explicit, see Middleware for the design.
Why Suprnova diverges
Laravel's HandleCors is auto-attached to the kernel and reads its
policy from config/cors.php. The shape works for PHP because the
config array is the one place a request-per-process framework can
share configuration without re-evaluating it per request. Suprnova
exposes the same options as a typed CorsConfig builder you register
explicitly with global_middleware!, which keeps the middleware
chain visible in bootstrap() and lets the compiler enforce the
allowlist-vs-wildcard choice (no Default for CorsConfig, so you
can't accidentally ship Access-Control-Allow-Origin: * because you
forgot to fill in a config value).
The other divergence is that preflights reach the middleware even on
unrouted paths. Laravel routes OPTIONS through its router so the
preflight matches an OPTIONS route (auto-registered for each REST
route). Suprnova's router has no OPTIONS routes; instead the server
runs the global middleware chain on unmatched requests before
returning 404, so a globally-installed CorsMiddleware short-circuits
the preflight with 204 before the not-found path is taken. That's
why CORS must be installed globally - a per-route registration
would never see the preflight.
Next
- Middleware - the trait, the chain, global vs per-route registration, terminable hooks
- CSRF - the other global middleware most apps install alongside CORS
- Routing - how routes are matched (and why preflights don't match), plus the no-fallback path the global chain runs on
- Request Lifecycle - where CORS sits in the chain relative to session, CSRF, and the handler
- Configuration - typed config patterns for middleware that need environment-driven settings
