Febasidocs
Concepts

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

TierStored onApplies when
1tenant.authConfig.corsThe request has no Client Key, or the key did not set its own list.
2client_keys.allowedOriginsThe request is authenticated by a Client Key that set its own list.

Resolution order on every request:

  1. Client Key sets allowedOrigins? Use it.
  2. Otherwise, resolve the tenant from the JWT payload or X-Tenant-Code, and use authConfig.cors.allowedOrigins.
  3. Otherwise, fall back to the platform CORS_ORIGINS env.

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
}
FieldDefaultNotes
allowedOrigins[]Either '*' (any origin) or an explicit list of full URLs.
allowedMethodsGET, POST, PUT, PATCH, DELETE, OPTIONSReturned in Access-Control-Allow-Methods on preflight.
allowedHeadersContent-Type, Authorization, X-Tenant-Code, X-API-KeyReturned in Access-Control-Allow-Headers.
exposeHeaders[]Headers the browser is allowed to read from the response.
maxAge600Preflight cache duration in seconds (max 86400).
credentialstrueSets 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.

allowedOriginscredentialsRequest OriginAccess-Control-Allow-Origin returned
["https://a"]truehttps://ahttps://a
["https://a"]truehttps://cheader omitted, browser blocks
"*"truehttps://chttps://c (reflected)
"*"falseany*

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/config invalidates 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

SymptomLikely causeFix
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 failsPreflight 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 CORSCustom 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 immediatelyCache 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.

  • IP allowlist — server-side source-IP gate; complementary to CORS.
  • Client Keys — where the per-key allowedOrigins field is set.
  • Tenant onboarding — when to configure authConfig.cors during provisioning.
  • Multi-tenancy — how Febasi Auth isolates tenants beyond the CORS layer.

On this page