Mixed-Auth CORS & CSRF
Problem
Section titled “Problem”Your API serves two types of clients:
- Browser clients — use CSRF cookies for request integrity, need CSRF protection, and make cross-origin requests subject to CORS policy
- Mobile or service clients — use Bearer tokens in the
Authorizationheader, are not vulnerable to CSRF, and do not send cookies
You need to configure CORS and CSRF so that browser routes get cross-origin cookie support with CSRF protection, while API-only routes skip CSRF entirely.
Solution
Section titled “Solution”Transport-Level CORS
Section titled “Transport-Level CORS”CORS is configured at the transport level — it applies to every
request before the route pipeline runs. For mixed-auth APIs, enable
credentials and explicitly list X-CSRF-TOKEN in the allowed
headers so browsers can send the CSRF token on cross-origin requests.
import { compose, cors } from "@centralping/ergo";
const pipeline = compose( cors({ origins: ["https://app.example.com"], allowCredentials: true, allowHeaders: [ "Content-Type", "Authorization", "X-CSRF-TOKEN", ], }),);import createRouter from "@centralping/ergo-router";
const router = createRouter({ transport: { cors: { origin: "https://app.example.com", credentials: true, allowedHeaders: [ "Content-Type", "Authorization", "X-CSRF-TOKEN", ], }, },});Per-Route CSRF
Section titled “Per-Route CSRF”CSRF runs in the pipeline at Stage 2 (Authorization) — it can be
enabled or disabled per route. Set csrf options on routes that use
cookie-based auth, and csrf: false on routes that use Bearer tokens.
import { compose, cookie, csrf, authorization } from "@centralping/ergo";
const csrfMiddleware = csrf({ secret: process.env.CSRF_SECRET });
// Browser route — CSRF protection + Bearer authconst browserPipeline = compose( {fn: cookie(), setPath: "cookies"}, {fn: csrfMiddleware.verify, setPath: "csrf"}, {fn: authorization({ strategies: [{ type: "Bearer", authorizer: async (attributes, token) => { const session = await verifySessionToken(token); return session ? { authorized: true, info: { user: session.user } } : { authorized: false }; }, }], }), setPath: "auth"},);
// API route — Bearer auth, no CSRFconst apiPipeline = compose( {fn: authorization({ strategies: [{ type: "Bearer", authorizer: async (attributes, token) => { const user = await verifyJwt(token); return user ? { authorized: true, info: { user } } : { authorized: false }; }, }], }), setPath: "auth"},);import createRouter from "@centralping/ergo-router";
const router = createRouter({ defaults: { cookie: true, csrf: { secret: process.env.CSRF_SECRET }, authorization: { strategies: [{ type: "Bearer", authorizer: async (attributes, token) => { const user = await verifyJwt(token); return user ? { authorized: true, info: { user } } : { authorized: false }; }, }], }, },});
// Inherits CSRF from defaults — browser routerouter.post("/account/settings", { execute: async (req, res, acc) => ({ response: { body: { updated: true } }, }),});
// Disables CSRF — API-only routerouter.post("/api/webhooks", { csrf: false, execute: async (req, res, acc) => ({ response: { body: { received: true } }, }),});Combined Configuration
Section titled “Combined Configuration”A complete router with transport CORS, default CSRF, and per-route overrides:
import { compose, cookie, cors, csrf, authorization,} from "@centralping/ergo";
const csrfMiddleware = csrf({ secret: process.env.CSRF_SECRET });
const bearerStrategy = { type: "Bearer", authorizer: async (attributes, token) => { const user = await verifyJwt(token); return user ? { authorized: true, info: { user } } : { authorized: false }; },};
const corsMiddleware = cors({ origins: ["https://app.example.com"], allowCredentials: true, allowHeaders: [ "Content-Type", "Authorization", "X-CSRF-TOKEN", ],});
// Browser route: CORS + cookies + CSRF + authconst settingsPipeline = compose( corsMiddleware, {fn: cookie(), setPath: "cookies"}, {fn: csrfMiddleware.verify, setPath: "csrf"}, {fn: authorization({ strategies: [bearerStrategy] }), setPath: "auth"}, (req, res, acc) => ({ response: { body: { userId: acc.auth.user.id } }, }),);
// API route: CORS + auth (no cookies, no CSRF)const webhookPipeline = compose( corsMiddleware, {fn: authorization({ strategies: [bearerStrategy] }), setPath: "auth"}, (req, res, acc) => ({ response: { body: { received: true } }, }),);import http from "node:http";import createRouter from "@centralping/ergo-router";
const router = createRouter({ transport: { requestId: {}, security: {}, cors: { origin: "https://app.example.com", credentials: true, allowedHeaders: [ "Content-Type", "Authorization", "X-CSRF-TOKEN", ], }, }, defaults: { cookie: true, csrf: { secret: process.env.CSRF_SECRET }, authorization: { strategies: [{ type: "Bearer", authorizer: async (attributes, token) => { const user = await verifyJwt(token); return user ? { authorized: true, info: { user } } : { authorized: false }; }, }], }, },});
// Browser route — inherits CSRF + auth from defaultsrouter.get("/account/settings", { execute: (req, res, acc) => ({ response: { body: { userId: acc.auth.user.id } }, }),});
router.post("/account/settings", { execute: async (req, res, acc) => ({ response: { body: { updated: true } }, }),});
// API route — disables CSRF, inherits authrouter.post("/api/webhooks", { csrf: false, execute: async (req, res, acc) => ({ response: { body: { received: true } }, }),});
// Public route — disables both CSRF and authrouter.get("/health", { csrf: false, authorization: false, execute: () => ({ response: { body: { status: "healthy" } }, }),});
const server = http.createServer(router.handle());server.listen(3000);Explanation
Section titled “Explanation”Why CORS Is Transport-Level and CSRF Is Per-Route
Section titled “Why CORS Is Transport-Level and CSRF Is Per-Route”CORS and CSRF operate at different layers of the request lifecycle:
-
CORS validates the origin of the request before any route logic runs. It operates on HTTP transport semantics (preflight
OPTIONShandling, response headers) and applies uniformly to all routes. In ergo-router, CORS is configured intransportbecause it runs before route matching. -
CSRF validates request integrity — it ensures a browser request was intentionally initiated by the user, not by a malicious cross-site script. It operates at the pipeline level (Stage 2: Authorization) because different routes may need different CSRF policies depending on their auth mechanism.
When to Enable CSRF
Section titled “When to Enable CSRF”| Auth mechanism | Client type | CSRF needed? | Rationale |
|---|---|---|---|
| Session cookie | Browser | Yes | Browsers auto-attach cookies on every request — CSRF prevents forged submissions |
| Bearer token | Mobile/service | No | Tokens are explicitly attached by the client — not auto-sent by the browser |
| Cookie + Bearer (mixed) | Both | Per-route | Enable CSRF on cookie-auth routes, disable on Bearer-only routes |
| None (public) | Any | No | No credentials to protect |
sameSite: 'Strict' and Cross-Site Requests
Section titled “sameSite: 'Strict' and Cross-Site Requests”ergo’s CSRF middleware locks both cookies to sameSite: 'Strict'. This
means CSRF cookies are sent on same-site requests and withheld on
cross-site requests. The SameSite attribute operates on the
site boundary (registrable domain), not the origin boundary — so
app.example.com and api.example.com are cross-origin but
same-site (both under example.com).
This has two implications:
-
Cross-origin, same-site SPAs (e.g.,
app.example.comcallingapi.example.com) —SameSite=Strictcookies are delivered because the request is same-site. However, the CSRF cookies are host-only by default (scoped to the host that set them). If your API atapi.example.comsets CSRF cookies, they are sent back toapi.example.comon same-site requests — no additionaldomainconfiguration is needed for this topology. -
Cross-site integrations (e.g.,
other-domain.comcalling your API) — the browser will not sendSameSite=Strictcookies on cross-site requests. These clients should use Bearer token authentication withcsrf: false.
Bearer Clients and Stale CSRF Cookies
Section titled “Bearer Clients and Stale CSRF Cookies”When a route has csrf: false, the CSRF middleware is removed from
the pipeline entirely — it does not run. A Bearer client that
happens to have stale CSRF cookies from a previous browser session is
unaffected because:
- On
csrf: falseroutes, the cookie is ignored — no verification occurs - On CSRF-enabled routes, a Bearer client without the
X-CSRF-TOKENheader would fail CSRF verification with 403 — this is correct behavior, since that route expects cookie-based auth
The resolve() function in ergo-router handles each config key
independently: csrf: false disables CSRF regardless of the
authorization setting, and vice versa.