Olympus Docs
CookbookMulti-tenant

Dynamic CORS allowlists per tenant

Allow each tenant to configure their own allowed origins

In a B2B SaaS, each customer's app calls your API from their domain. CORS allowed origins isn't static, it's per-tenant.

Pattern

CREATE TABLE tenant_cors_origins (
  tenant_id UUID NOT NULL,
  origin TEXT NOT NULL,
  PRIMARY KEY (tenant_id, origin)
);

Tenant admin adds their origins. CORS middleware checks dynamically.

Middleware

import cors from "cors";

app.use(cors({
  origin: async (origin, callback) => {
    if (!origin) return callback(null, false);  // no Origin header
    
    // Look up tenant from origin
    const allowed = await db`SELECT 1 FROM tenant_cors_origins WHERE origin = ${origin}`.first();
    if (allowed) return callback(null, true);
    
    return callback(new Error("origin_not_allowed"), false);
  },
  credentials: true,
}));

Origin → tenant lookup → allow/deny.

Caching

DB lookup per request is wasteful. Cache:

const originCache = new Map<string, boolean>();
const CACHE_TTL = 60_000;

async function isAllowed(origin: string) {
  if (originCache.has(origin)) return originCache.get(origin);
  const allowed = await db`...`.first();
  originCache.set(origin, !!allowed);
  setTimeout(() => originCache.delete(origin), CACHE_TTL);
  return !!allowed;
}

In-memory cache. For multi-instance: Redis.

Verification before adding

When tenant adds an origin, verify they own it (DNS):

async function addOrigin(tenantId, origin) {
  // Step 1: tenant adds origin (status: pending)
  await db.insert(corsOrigins).values({
    tenant_id: tenantId,
    origin,
    status: "pending",
    verification_token: crypto.randomUUID(),
  });
  
  // Tenant adds DNS TXT or HTML meta tag with the token
  // Then triggers verification:
}

async function verifyOrigin(tenantId, origin) {
  const record = await db`SELECT verification_token FROM cors_origins WHERE ...`.first();
  
  // Method A: DNS TXT
  const records = await dns.resolveTxt(`_olympus.${new URL(origin).hostname}`);
  if (records.flat().includes(record.verification_token)) {
    await db`UPDATE cors_origins SET status='verified' WHERE ...`;
    return true;
  }
  
  // Method B: fetch a known URL
  const res = await fetch(`${origin}/.well-known/olympus-verify`);
  const body = await res.text();
  if (body.trim() === record.verification_token) {
    await db`UPDATE cors_origins SET status='verified' WHERE ...`;
    return true;
  }
  
  return false;
}

Only verified origins are usable.

UI

In tenant admin panel:

<form action={addOrigin}>
  <input name="origin" placeholder="https://app.example.com" />
  <button>Add origin</button>
</form>

<table>
  {origins.map(o => (
    <tr>
      <td>{o.origin}</td>
      <td>
        {o.status === "verified" ? "✓" : (
          <Button onClick={() => verify(o.id)}>Verify</Button>
        )}
      </td>
      <td><Button onClick={() => remove(o.id)}>Remove</Button></td>
    </tr>
  ))}
</table>

Tenant manages their list.

Wildcard subdomains

For *.acme.com:

INSERT INTO cors_origins (origin) VALUES ('*.acme.com');

Lookup:

async function isAllowed(origin: string) {
  const url = new URL(origin);
  const exact = await db`SELECT 1 FROM cors_origins WHERE origin = ${origin}`.first();
  if (exact) return true;
  
  // Wildcard match
  const wildcard = await db`
    SELECT 1 FROM cors_origins 
    WHERE origin = ${`*.${url.hostname.split(".").slice(1).join(".")}`}
  `.first();
  return !!wildcard;
}

Use wildcards sparingly, broader trust.

Preflight performance

OPTIONS requests are short, but frequent. Cache aggressively:

@preflight method OPTIONS
header @preflight Access-Control-Max-Age "86400"  # 24h

Browsers cache 24h. Fewer round-trips.

Audit changes

INSERT INTO security_audit (event_type, actor_id, metadata)
VALUES (
  'cors_origin_added',
  $admin_id,
  '{"tenant": "$tenant", "origin": "$origin"}'
);

Track who added what.

Denied origin: silent or noisy

For rejected origins:

// Silent (no CORS header)
if (!isAllowed(origin)) return next();  // browser will reject

// Vs. explicit deny
if (!isAllowed(origin)) {
  res.status(403).send("origin_not_allowed");
  return;
}

Silent is technically correct (CORS isn't security per se). Explicit gives clearer error to debuggers.

Don't allow wildcard * with credentials

Browser-level rule: Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true is forbidden.

Always specify origin when credentials needed.

Limits

Tenant can add hundreds of origins. Some limit:

const MAX_ORIGINS_PER_TENANT = 50;
const count = await db`SELECT COUNT(*) FROM cors_origins WHERE tenant_id = ${tenantId}`.first();
if (count.count >= MAX_ORIGINS_PER_TENANT) {
  throw new Error("origin_limit");
}

Prevent storage abuse.

Removing

async function removeOrigin(tenantId, origin) {
  await db`DELETE FROM cors_origins WHERE tenant_id = ${tenantId} AND origin = ${origin}`;
  await audit({ event: "cors_origin_removed", ... });
  // Cache: invalidate
  originCache.delete(origin);
}

Effective immediately.

On this page