NodeJSCSRF Protection

CSRF Protection

CSRF (Cross-Site Request Forgery) tricks a logged-in user's browser into making a state-changing request to your app from a different site. Because the browser automatically sends cookies with every request to your domain — regardless of which site initiated the request — an attacker's page can submit a form to https://yourapp.com/transfer using the victim's session cookie, and your server has no way to tell the difference. The defences are: the SameSite cookie attribute, CSRF tokens, and the double-submit cookie pattern.

How CSRF works

Text
1. User logs into yourapp.com — gets a session cookie (sent automatically by browser).
2. User visits attacker.com (in another tab, or via an email link).
3. attacker.com has a hidden form or img tag that triggers a POST to yourapp.com/transfer.
4. The browser sends the request + the session cookie (because cookies are sent automatically).
5. yourapp.com sees a valid session and executes the transfer.

The attacker never sees the response — they just need the side-effect to happen.
Any cookie-authenticated endpoint that mutates state is vulnerable to CSRF by default
CSRF requires no XSS, no code injection, and no stolen credentials — just a user who is logged in and a request that carries their cookie. It affects any app using cookie-based authentication (sessions or cookie-stored JWTs) for state-changing endpoints. GET requests that don't modify state are generally safe (a browser GET via `<img src=...>` can be triggered cross-site, but it only leaks that you're logged in, not mutate data). POST/PUT/DELETE/PATCH are the targets.
Defence 1 — SameSite cookie attribute (modern default)

JS
res.cookie('session', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',     // ← blocks cross-site POST/PUT/DELETE; allows top-level GET navigation
  // sameSite: 'strict' — blocks even top-level cross-site navigation (most restrictive)
  // sameSite: 'none'  — requires secure: true; used for third-party embeds
})
`SameSite: lax` prevents most CSRF attacks and is the modern browser default
With `sameSite: 'lax'`, browsers send the cookie for same-site requests and top-level navigations (clicking a link to your site), but **not** for cross-origin subresource requests (forms submitted from other sites, XHR/fetch from other origins). This blocks the classic CSRF attack vector. Modern browsers default new cookies to `lax`, but explicitly setting it is good practice and ensures consistent behaviour. Use `strict` if your app doesn't need any cross-site navigation to preserve the session.
Defence 2 — CSRF tokens

Bash
npm install csrf-csrf    # or: csurf (older, deprecated)

Using csrf-csrf

JS
import { doubleCsrf } from 'csrf-csrf'

const { generateToken, doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__Host-psifi.x-csrf-token',
  cookieOptions: { secure: true, sameSite: 'lax' },
})

// Issue the token on form load:
app.get('/form', (req, res) => {
  const csrfToken = generateToken(req, res)     // sets a cookie + returns the token
  res.render('form', { csrfToken })
})

// Protect state-changing routes:
app.post('/transfer', doubleCsrfProtection, transferHandler)

In your HTML form

HTML
<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="<%= csrfToken %>" />
  <!-- other fields -->
</form>
CSRF token: a server-issued secret the form must echo back — cross-site pages can't read it
A CSRF token is a unique secret embedded in the form (or a request header for SPA/API flows). When the form is submitted, the server compares the submitted token against one stored in the session or a signed cookie. A cross-site attacker can trigger a request but cannot read the token — it's in your page's DOM which is protected by same-origin policy. The comparison fails and the request is rejected. CSRF tokens remain the gold standard for apps that can't rely solely on `SameSite` (e.g. sites that need IE11 compatibility, third-party embeds, or extra defence-in-depth).
APIs and JWT-in-header: not CSRF-vulnerable

Text
// Stateless APIs using Authorization: Bearer <token> in the header:
GET /api/data
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

→ NOT vulnerable to CSRF.
  A cross-site request cannot set custom request headers (blocked by CORS pre-flight).
  The browser only auto-sends cookies — not Authorization headers.
Bearer token APIs are not CSRF-vulnerable — the browser can't auto-attach a header cross-site
CSRF relies on the browser automatically sending credentials (cookies). A token stored in JavaScript memory or `localStorage` and sent via the `Authorization` header cannot be attached by a cross-site page — the attacker's page runs under a different origin and CORS blocks cross-origin custom headers. So a pure stateless JWT API with no cookies is not CSRF-vulnerable. The risk returns if you store the JWT in a cookie instead of a header.
CORS is not CSRF protection
CORS limits what responses the attacker can READ — it does not stop the request from being SENT
A common misconception: "I have CORS configured, so I'm protected from CSRF." CORS controls whether a cross-origin page can **read** the response. The request is still **sent** and your server still **processes** it. A cross-origin POST that transfers money executes the transfer whether or not CORS lets the attacker's page read the 200 response. CSRF protection prevents the unwanted request from happening at all — CORS does not.
CSRF checklist
  • SameSite: lax (or strict) on all session/auth cookies.

  • Add CSRF tokens for traditional form-based apps (server-rendered HTML with cookie sessions).

  • Authorization header + JWT for SPAs — no cookies = no CSRF.

  • Verify Origin/Referer headers as a secondary check (can be absent; not a primary defence).

  • Don't use GET for state-changing actions — POST/PUT/DELETE only.

  • CORS is not CSRF protection — understand what each actually does.

Next
Keep your keys out of source control: [Managing Secrets & API Keys](/nodejs/secrets-management).