CSRF stance
BoringStack ships without CSRF token middleware. Session cookies are SameSite=Strict in production, and the default deployment topology keeps the API and the SPA on the same registrable domain. With both in place, a CSRF attack can’t reach the surface we ship.
This page exists because SameSite=Strict is a deployment constraint. It disallows certain topologies, and operators forking the template need to know that before they ship.
What’s actually deployed
Section titled “What’s actually deployed”- Session cookies:
HttpOnly,Secure,SameSite=Strict, signed. Set by the API on/auth/loginand refreshed by/auth/refresh. Seeapps/api/src/lib/cookies/cookie-utils.ts. - CORS:
credentials: trueis enabled, but only whenALLOWED_ORIGINSis non-empty. The compose default leaves it empty. Traefik routes/api/*on the same host that serves the SPA, so no cross-origin call ever happens. Seeapps/api/src/config/security/security.ts. - No CSRF token middleware. There is no
X-CSRF-Tokenheader, no double-submit cookie, no synchroniser pattern.
Why this is safe
Section titled “Why this is safe”A CSRF attack works by having the victim’s browser send a state-changing request to your API while carrying the victim’s auth cookie. SameSite=Strict instructs the browser to omit the cookie on any cross-site request, including form posts, image loads, link clicks, fetch, and EventSource. The request reaches the API without credentials and is treated as anonymous.
The threat model CSRF tokens defend against (the attacker can’t read the cookie, but can trick the browser into sending it cross-site) is closed here. The browser refuses to send the cookie cross-site in the first place. A token would only defend against a request that’s already unauthenticated.
Rails 7+ ships this for default cookie sessions. MDN and OWASP both recommend SameSite=Strict when your target browsers support it, which every major browser has for 4+ years.
What this topology supports
Section titled “What this topology supports”- Same-origin deployment: SPA at
app.example.com, API atapp.example.com/api. This is the compose default, and whatinfra/compose/compose/production-labels.ymlships. - Apex + subdomain on the same registrable domain: SPA at
app.example.com, API atapi.example.com. Still same-site under the registrable-domain rule, still safe withSameSite=Strict. CookieDomain=.example.com.
What this topology excludes
Section titled “What this topology excludes”- True cross-site auth: SPA at
app.foo.comcalling an API atapi.bar.com. The browser won’t send the session cookie. The user can never sign in. - Embedded scenarios: Loading the SPA in an iframe on a third-party host that needs to make authenticated API calls. Same problem: cookies are not sent cross-site.
- OAuth pop-up redirects from a third-party domain: Works for OAuth itself (state cookies opt into SameSite=Lax in
apps/api/src/lib/auth/oauth.service.tsfor the duration of the round-trip), but is not the general API auth flow.
If your deployment needs any of the above, add CSRF tokens (see the next section).
Adding CSRF tokens later
Section titled “Adding CSRF tokens later”The shape we’d add (when it’s needed, not before):
- Token mint: a new
/api/v1/auth/csrfendpoint that returns a freshly-signed token and sets a non-HttpOnlyXSRF-TOKENcookie. Use the existing JWT signing key. - Middleware: a new
apps/api/src/middleware/csrf.tsthat runs afterrequestLoggerand before any route. For non-GET/HEADrequests, verify theX-CSRF-Tokenheader matches the cookie’s signed value. - UI integration:
apps/ui/src/lib/api/openapi.tsreads the cookie viadocument.cookie(which is allowed for non-HttpOnlycookies) and adds the header to every mutation. - CORS adjustment: relax cookie
SameSitetoLax(orNonewithSecurefor true cross-site).
This is a one-day change, and not in scope for v1.0 because no operator has asked for it yet. When you’re the first, file a PR. The shape above is the design.