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-configuration→Access-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: trueYour 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: trueReflecting 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.comIf you need true wildcards, terminate CORS at Caddy with explicit logic.
Preflight caching
res.header("Access-Control-Max-Age", "86400"); // 24hBrowsers 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.