BillTracker/docs/CSRF-SPA-Setup.md

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

  1. The csrfTokenProvider middleware sets res.locals.csrfToken but the CSRF cookie wasn't being generated for SPA index.html requests
  2. The * route serving index.html was bypassing CSRF cookie generation
  3. 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:

  1. Server sets a CSRF cookie with a cryptographically secure token
  2. Client reads the cookie via JavaScript and sends it in the x-csrf-token header for state-changing requests
  3. 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

  1. Request enters server.js

    • csrfTokenProvider middleware runs on every request (via app.use(csrfTokenProvider))
    • This ensures res.locals.csrfToken is always available
  2. SPA route app.get('*') is hit

    • Explicitly calls getCsrfToken(req, res) before sending index.html
    • This guarantees the CSRF cookie is set even for the initial SPA load
  3. Frontend reads the cookie

    // Example from frontend
    function getCsrfToken() {
      const match = document.cookie.match(/bt_csrf_token=([^;]+)/);
      return match ? match[1] : null;
    }
    
  4. 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),
    });
    
  5. Server validates

    • csrfMiddleware extracts the token from:
      • x-csrf-token header (preferred for API)
      • csrf_token query parameter (form submissions)
      • csrf_token body 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 requests
  • strict: Most secure but may break SPA functionality across domains
  • none: Requires Secure=true (HTTPS only)

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:

  1. Verify CSRF_HTTP_ONLY=false is set
  2. Check browser DevTools → Application → Cookies → Ensure bt_csrf_token cookie exists
  3. Verify the frontend is sending x-csrf-token header with correct token value

Symptoms:

  • No bt_csrf_token cookie appears in browser DevTools

Check:

  1. Verify app.use(csrfTokenProvider) is in server.js
  2. Ensure getCsrfToken(req, res) is called in the SPA route handler
  3. Check that cookies are not blocked by browser settings or extensions

Issue: Token mismatch

Symptoms:

  • Cookie exists but validation still fails

Check:

  1. Clear browser cookies and refresh
  2. Ensure only one CSRF cookie exists (no duplicate names)
  3. Check server restart didn't generate a new cookie before the SPA read the old one

References