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
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.
Defence 1 — SameSite cookie attribute (modern default)
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
})Defence 2 — CSRF tokens
npm install csrf-csrf # or: csurf (older, deprecated)
Using csrf-csrf
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
<form method="POST" action="/transfer"> <input type="hidden" name="_csrf" value="<%= csrfToken %>" /> <!-- other fields --> </form>
APIs and JWT-in-header: not CSRF-vulnerable
// 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.
CORS is not CSRF protection
CSRF checklist
SameSite: lax(orstrict) 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/Refererheaders 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.