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( cookie(), {fn: csrfMiddleware.verify, setPath: 'csrf'}, authorization({ strategies: [{ type: 'Bearer', authorizer: async (attributes, token) => { const session = await verifySessionToken(token); return session ? {authorized: true, info: {user: session.user}} : {authorized: false}; }, }], }),);
// API route — Bearer auth, no CSRFconst apiPipeline = compose( authorization({ strategies: [{ type: 'Bearer', authorizer: async (attributes, token) => { const user = await verifyJwt(token); return user ? {authorized: true, info: {user}} : {authorized: false}; }, }], }),);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, cookie(), {fn: csrfMiddleware.verify, setPath: 'csrf'}, authorization({strategies: [bearerStrategy]}), (req, res, acc) => ({ response: {body: {userId: acc.auth.user.id}}, }),);
// API route: CORS + auth (no cookies, no CSRF)const webhookPipeline = compose( corsMiddleware, authorization({strategies: [bearerStrategy]}), (req, res, acc) => ({ response: {body: {received: true}}, }),);import createRouter, {graceful} 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'}}, }),});
await graceful(router.handle(), {port: 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.
Related Documentation
Section titled “Related Documentation”- CORS middleware guide — origin allowlist, preflight handling, credential support options
- CSRF middleware guide — token lifecycle, cookie prerequisites, auto-dispatch in ergo-router
- Cookie middleware guide — parsing, signing, SameSite configuration
- Authorization middleware guide — auth strategy configuration, scheme matching