CORS configuration
Per-tenant and per-key origin allowlists, wildcard semantics with credentials, precedence rules, and troubleshooting.
CORS in Febasi Auth is tenant-aware. Each tenant declares which
browser origins are allowed to talk to its slice of the API, and any
Client Key can override that allowlist with its own. The platform-level
CORS_ORIGINS env stays as a fallback for tenants that have not
configured anything.
CORS only matters for browser callers. Server-to-server requests — the recommended pattern for public signup, BFFs, and back-office tooling — ignore CORS entirely.
The two-tier model
| Tier | Stored on | Applies when |
|---|---|---|
| 1 | tenant.authConfig.cors | The request has no Client Key, or the key did not set its own list. |
| 2 | client_keys.allowedOrigins | The request is authenticated by a Client Key that set its own list. |
Resolution order on every request:
- Client Key sets
allowedOrigins? Use it. - Otherwise, resolve the tenant from the JWT payload or
X-Tenant-Code, and useauthConfig.cors.allowedOrigins. - Otherwise, fall back to the platform
CORS_ORIGINSenv.
A miss at every tier means the request is blocked.
Tier 1 — Tenant CORS
The full shape is:
// authConfig.cors
{
"allowedOrigins": ["https://app.acme.com", "https://admin.acme.com"],
"allowedMethods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
"allowedHeaders": ["Content-Type", "Authorization", "X-Tenant-Code", "X-API-Key"],
"exposeHeaders": [],
"maxAge": 600,
"credentials": true
}| Field | Default | Notes |
|---|---|---|
allowedOrigins | [] | Either '*' (any origin) or an explicit list of full URLs. |
allowedMethods | GET, POST, PUT, PATCH, DELETE, OPTIONS | Returned in Access-Control-Allow-Methods on preflight. |
allowedHeaders | Content-Type, Authorization, X-Tenant-Code, X-API-Key | Returned in Access-Control-Allow-Headers. |
exposeHeaders | [] | Headers the browser is allowed to read from the response. |
maxAge | 600 | Preflight cache duration in seconds (max 86400). |
credentials | true | Sets Access-Control-Allow-Credentials: true. Affects wildcard. |
Configuring
PATCH /api/v1/tenants/:id/config
Authorization: Bearer <jwt-with-tenants:update>{
"cors": {
"allowedOrigins": ["https://app.acme.com"],
"credentials": true
}
}Anything you omit keeps its current value. To clear the allowlist, send an empty array — the change is honored on the next request after the cache refresh (up to 60 seconds; the docs page on the resolver covers eager invalidation).
Tier 2 — Per-Client-Key CORS
Each Client Key may carry its own allowedOrigins. When the request is
authenticated by the key, the key's list replaces the tenant's list
for that request.
POST /api/v1/client-keys
Authorization: Bearer <jwt-with-client-keys:create>{
"name": "Acme web checkout",
"scopes": ["users:create"],
"tenantAccessLevel": "specific",
"allowedTenantIds": ["01HX0..."],
"allowedOrigins": ["https://checkout.acme.com"]
}allowedOrigins on a key accepts the same '*' | string[] shape as the
tenant tier. Leaving it null (the default) inherits from the tenant.
When to set it
- Browser-direct keys (future publishable keys). Pin the origin allowlist on the key itself so a leaked or misissued key only works from the intended domain.
- Backend keys with extra restriction. A key that should only be callable from a specific internal proxy can pin that proxy's origin — belt-and-braces against the key being used elsewhere.
When not to set it
- Pure server-to-server keys (no browser involved): leave it null. CORS is not enforced for non-browser callers anyway; setting an allowlist adds no protection and creates a future maintenance trap.
Precedence and merge rules
The Tier-2 list replaces the Tier-1 list — they do not merge. The intuition: a Client Key with a stricter origin allowlist than its tenant should remain strict regardless of changes to the tenant config; a Client Key with a more permissive list should not be silently narrowed by the tenant.
If you want a Client Key to inherit "whatever the tenant currently
allows", leave the key's allowedOrigins unset.
Wildcard and credentials
When allowedOrigins = '*' is combined with credentials: true, the
CORS specification forbids returning Access-Control-Allow-Origin: *.
The resolver reflects the request Origin instead. Same effective
permissiveness, fully spec-compliant.
allowedOrigins | credentials | Request Origin | Access-Control-Allow-Origin returned |
|---|---|---|---|
["https://a"] | true | https://a | https://a |
["https://a"] | true | https://c | header omitted, browser blocks |
"*" | true | https://c | https://c (reflected) |
"*" | false | any | * |
credentials: false is the only way to legitimately return *. Use it
only when no cookies, no Authorization header, and no client TLS certs
are required.
Common configurations
A tenant with one production frontend:
{
"cors": {
"allowedOrigins": ["https://app.acme.com"],
"credentials": true
}
}Production, staging, and local dev — all from the same tenant:
{
"cors": {
"allowedOrigins": [
"https://app.acme.com",
"https://staging.acme.com",
"http://localhost:3000"
],
"credentials": true
}
}http://localhost:3000 is intentional for dev. Drop it from
production tenants.
A marketing or status page that calls a read-only endpoint with no cookies:
{
"cors": {
"allowedOrigins": "*",
"credentials": false
}
}credentials: false makes the wildcard cheap and spec-compliant
without reflection.
Strict origin on a single Client Key meant for a checkout widget, even though the tenant allows broader access:
{
"name": "Acme checkout widget",
"scopes": ["users:create"],
"tenantAccessLevel": "specific",
"allowedTenantIds": ["01HX0..."],
"allowedOrigins": ["https://checkout.acme.com"]
}Preflight (OPTIONS)
Browsers send a preflight OPTIONS request before the real request
whenever the call uses a non-simple method, a custom header, or carries
credentials. Preflight has no authentication — the browser does not send
cookies, JWTs, or API keys on it.
Because of that, the resolver allows preflight if the Origin appears in
the union of all tenant and key allowlists on the instance. The actual
request is then re-checked against the specific tenant or key that
authenticates it — which is what protects data. Preflight only gates the
browser's willingness to send the real request.
If your preflight is being rejected, that means the Origin is not in any
allowlist on the instance. Add it to the tenant's authConfig.cors or
to the relevant Client Key's allowedOrigins.
Cache and propagation
The resolver caches lookups in memory with a 60-second TTL. Configuration changes propagate as follows:
PATCH /tenants/:id/configinvalidates that tenant's entries on the instance handling the request.- Client Key create / update / revoke invalidates that key's entries.
- Multi-instance deployments rely on the 60-second TTL for peer instances until pub/sub propagation lands.
In practice: expect up to 60 seconds of lag between a CORS change and its effect across all instances. For staging-day changes, schedule a brief window.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Browser console: "Access to fetch at … from origin … has been blocked by CORS policy" | Origin not in any allowlist on the instance, or preflight failed. | Add the origin to authConfig.cors.allowedOrigins. Wait up to 60s for cache refresh. |
| "The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard" | credentials: true with allowedOrigins: '*' — the server should be reflecting Origin, not *. | Verify the resolver is actually running (check GET /health once the diagnostic field lands). |
curl works, browser fails | Preflight rejected. curl doesn't preflight; browsers do. | Test with curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST" -i …. |
X-API-Key not allowed by CORS | Custom header not listed in allowedHeaders. | Add X-API-Key to authConfig.cors.allowedHeaders (it's in the default, but custom configs may drop it). |
| Tenant change does not take effect immediately | Cache TTL. | Wait up to 60s, or force a service restart for an emergency cutover. |
CORS vs IP allowlist
CORS is a browser-enforced read protection. It does not stop non-browser clients (curl, scripts, malicious tooling) from calling the endpoint — it only stops the browser from letting JS read the response. If you need to restrict who can reach the endpoint regardless of the client, that is a job for the IP allowlist, which is enforced server-side, before any handler runs.
Configure both for full coverage of browser and non-browser callers.
Related
- IP allowlist — server-side source-IP gate; complementary to CORS.
- Client Keys — where the per-key
allowedOriginsfield is set. - Tenant onboarding — when to
configure
authConfig.corsduring provisioning. - Multi-tenancy — how Febasi Auth isolates tenants beyond the CORS layer.