6.8 KiB
CSRF SPA Setup Guide
Overview
This document describes the CSRF (Cross-Site Request Forgery) protection implementation for the Single Page Application (SPA) frontend.
Problem Statement
Symptoms
- SPA (Single Page Application) couldn't read the CSRF cookie
- Create user and other POST requests failed with:
"Your session has expired or this request may be fraudulent" - The CSRF cookie was set as HttpOnly by default, preventing JavaScript access
Root Causes
- The
csrfTokenProvidermiddleware setsres.locals.csrfTokenbut the CSRF cookie wasn't being generated for SPAindex.htmlrequests - The
*route servingindex.htmlwas bypassing CSRF cookie generation - HttpOnly cookies cannot be read by JavaScript, which prevented the SPA from extracting the token
Solution
Implementation in server.js
The fix ensures the CSRF cookie is set before sending the SPA index.html:
const { getCsrfToken } = require('./middleware/csrf');
app.get('*', (req, res) => {
// Set CSRF cookie if not present (needed for SPA to read token)
getCsrfToken(req, res);
res.sendFile(path.join(DIST, 'index.html'));
});
Environment Configuration
Add these environment variables to your .env file:
# CSRF Settings for SPA
CSRF_HTTP_ONLY=false # Allow JavaScript to read cookie (SPA mode)
CSRF_SAME_SITE=lax # Better SPA compatibility (vs 'strict')
CSRF_COOKIE_NAME=bt_csrf_token # Customize cookie name if needed
Note: When
CSRF_HTTP_ONLY=false, the cookie is accessible via JavaScript (document.cookie). This is required for SPA CSRF token extraction but should only be used when the application is served from the same origin.
How It Works
Double-Submit Pattern
The implementation uses the double-submit cookie pattern:
- Server sets a CSRF cookie with a cryptographically secure token
- Client reads the cookie via JavaScript and sends it in the
x-csrf-tokenheader for state-changing requests - Server validates that the header token matches the cookie token
Flow Diagram
┌─────────────┐ ┌─────────────┐
│ Browser │ │ Server │
└──────┬──────┘ └──────┬──────┘
│ │
│ GET / │
│───────────────────────>│
│ │
│ │ Set CSRF cookie (httpOnly=false)
│ │ Set-Cookie: bt_csrf_token=<token>; Path=/; SameSite=lax
│ │
│ │ Send index.html
│ │ └─> getCsrfToken(req, res) called
│ │
│ index.html + JS │
│<───────────────────────┘
│
│ Read cookie: document.cookie
│ Extract token from bt_csrf_token=<token>
│
│ POST /api/bills
│ x-csrf-token: <token>
│───────────────────────>
│ │
│ │ Validate: header token == cookie token
│ │ If match → process request
│ │ If mismatch → 403 Forbidden
│ │
│ 200 OK / 403 │
│<───────────────────────┘
Code Flow
-
Request enters
server.jscsrfTokenProvidermiddleware runs on every request (viaapp.use(csrfTokenProvider))- This ensures
res.locals.csrfTokenis always available
-
SPA route
app.get('*')is hit- Explicitly calls
getCsrfToken(req, res)before sendingindex.html - This guarantees the CSRF cookie is set even for the initial SPA load
- Explicitly calls
-
Frontend reads the cookie
// Example from frontend function getCsrfToken() { const match = document.cookie.match(/bt_csrf_token=([^;]+)/); return match ? match[1] : null; } -
API calls include the token
const token = getCsrfToken(); fetch('/api/bills', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-csrf-token': token, }, body: JSON.stringify(data), }); -
Server validates
csrfMiddlewareextracts the token from:x-csrf-tokenheader (preferred for API)csrf_tokenquery parameter (form submissions)csrf_tokenbody field (form submissions)
- Compares against the cookie token
- Returns 403 if validation fails
Security Considerations
HttpOnly Setting
- Default (Secure):
CSRF_HTTP_ONLY=true— Cookie is NOT accessible via JavaScript, only sent automatically with requests - SPA Mode:
CSRF_HTTP_ONLY=false— Cookie IS accessible via JavaScript, enabling the double-submit pattern
SameSite Attribute
lax(recommended for SPA): Allows cookie to be sent with top-level navigations but blocks cross-site POST requestsstrict: Most secure but may break SPA functionality across domainsnone: RequiresSecure=true(HTTPS only)
Cookie Name
Default: bt_csrf_token
Customize via CSRF_COOKIE_NAME if running multiple applications on the same domain.
Troubleshooting
Issue: "CSRF token validation failed" on SPA
Symptoms:
- POST/PUT/DELETE/PATCH requests fail with 403
- Error message: "Your session has expired or this request may be fraudulent"
Check:
- Verify
CSRF_HTTP_ONLY=falseis set - Check browser DevTools → Application → Cookies → Ensure
bt_csrf_tokencookie exists - Verify the frontend is sending
x-csrf-tokenheader with correct token value
Issue: Cookie not being set
Symptoms:
- No
bt_csrf_tokencookie appears in browser DevTools
Check:
- Verify
app.use(csrfTokenProvider)is inserver.js - Ensure
getCsrfToken(req, res)is called in the SPA route handler - Check that cookies are not blocked by browser settings or extensions
Issue: Token mismatch
Symptoms:
- Cookie exists but validation still fails
Check:
- Clear browser cookies and refresh
- Ensure only one CSRF cookie exists (no duplicate names)
- Check server restart didn't generate a new cookie before the SPA read the old one
References
middleware/csrf.js— Core CSRF implementationserver.js— Express server with SPA route- OWASP CSRF Prevention Cheat Sheet