Olympus Docs
SecurityWeb attacks

CORS configuration and pitfalls

Cross-origin resource sharing, what it does, what it doesn't

CORS controls whether JavaScript running on one origin can read responses from another origin. It is not a server-side security mechanism, the server still returns the response. CORS just tells the browser whether to expose it.

Olympus CORS posture

Hera

Hera serves login UI. It is the origin user-agents authenticate from. It does not need permissive CORS, it serves HTML directly and posts back to itself.

# hera Next.js
async headers() {
  return [{
    source: "/(.*)",
    headers: [
      { key: "Access-Control-Allow-Origin", value: "" }, // none
    ],
  }];
}

Hydra (OAuth2)

OAuth2 endpoints are designed for cross-origin use:

  • /.well-known/openid-configurationAccess-Control-Allow-Origin: * (public metadata).
  • /.well-known/jwks.json*.
  • /oauth2/token → set per-client. SPAs need their origin allowed.
  • /userinfo → set per-client. Same.

Configure in client registration:

hydra create client \
  --name "My SPA" \
  --allowed-cors-origins "https://my-spa.com" \
  ...

Hydra emits Access-Control-Allow-Origin: https://my-spa.com only on requests from that origin.

Kratos (your apps)

Kratos itself doesn't expose endpoints meant to be called cross-origin from arbitrary JS. Hera (same origin) is the consumer. CORS is effectively closed.

If you're building a custom UI on top of Kratos's public API, configure:

# kratos.yml
serve:
  public:
    cors:
      enabled: true
      allowed_origins:
        - https://your-custom-ui.com
      allowed_methods: [GET, POST]
      allowed_headers: [Authorization, Content-Type]
      allow_credentials: true

Your app's API

Two patterns:

Same-origin (BFF): API on same host as web app. CORS unnecessary.

Cross-origin (SPA + separate API): Configure CORS to allow your SPA origin specifically. Never * with credentials.

import cors from "cors";
app.use(cors({
  origin: "https://my-spa.com",
  credentials: true,
}));

Common mistakes

Access-Control-Allow-Origin: * with credentials

Browsers reject this combo: if you want cookies, origin must be specific.

Access-Control-Allow-Origin: https://my-spa.com
Access-Control-Allow-Credentials: true

Reflecting the Origin header

// DON'T:
res.header("Access-Control-Allow-Origin", req.headers.origin);
res.header("Access-Control-Allow-Credentials", "true");

This is equivalent to allowing any origin to read responses with cookies. Use an allowlist.

// DO:
const allowed = ["https://my-spa.com", "https://staging.my-spa.com"];
if (allowed.includes(req.headers.origin)) {
  res.header("Access-Control-Allow-Origin", req.headers.origin);
  res.header("Access-Control-Allow-Credentials", "true");
}

Trusting CORS as auth

CORS does NOT prevent unauthorized access. A non-browser client (curl, server-to-server) doesn't enforce CORS at all. Your API must still check authentication on every request.

CORS for preflight only

For OPTIONS requests, return immediately, don't process body. The CORS middleware should short-circuit.

Wildcards in allowed_origins

Hydra and Kratos do NOT support *.your-domain patterns. List each subdomain explicitly:

allowed_origins:
  - https://app.your-domain.com
  - https://admin.your-domain.com

If you need true wildcards, terminate CORS at Caddy with explicit logic.

Preflight caching

res.header("Access-Control-Max-Age", "86400"); // 24h

Browsers cache OPTIONS responses. Reduces request volume during normal use, but if you change CORS config, users with cached preflight will misbehave for up to a day.

On this page