CSRF Protection
Cross-Site Request Forgery (CSRF) tricks an authenticated user’s browser into sending an unwanted request to your app — for example, submitting a hidden form that transfers money or changes an email address. Because the browser automatically attaches session cookies to any request bound for your domain, the malicious request looks legitimate to the server. CSRF only matters when your app authenticates with cookies that the browser sends ambiently; understanding that distinction is the key to protecting Express APIs correctly.
How a CSRF attack works
Imagine a user is logged into bank.example with a session cookie. They visit a malicious page that contains:
<form action="https://bank.example/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="5000" />
</form>
<script>document.forms[0].submit()</script>
The browser submits the form to bank.example, attaching the victim’s session cookie. The server sees a valid session and processes the transfer. The attacker never needs to read any response — the side effect alone is the goal. CSRF defenses work by requiring a secret the attacker’s page cannot know or read.
Token-based protection
The classic defense is the synchronizer token pattern: the server issues a per-session secret, embeds a derived token in every form or sent via a header, and rejects state-changing requests whose token does not validate. The attacker’s cross-origin page cannot read this token, so it cannot forge a valid request.
The original csurf package is deprecated. The modern, maintained replacement is csrf-csrf, which implements the double-submit cookie pattern with a signed, HMAC-bound token.
npm install csrf-csrf cookie-parser
const express = require("express");
const cookieParser = require("cookie-parser");
const { doubleCsrf } = require("csrf-csrf");
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
const { generateCsrfToken, doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
getSessionIdentifier: (req) => req.session?.id ?? req.ip,
cookieName: "__Host-csrf",
cookieOptions: { sameSite: "lax", secure: true, httpOnly: true },
getCsrfTokenFromRequest: (req) => req.headers["x-csrf-token"],
});
// Expose a token to the client (e.g. for a SPA or form render)
app.get("/csrf-token", (req, res) => {
res.json({ csrfToken: generateCsrfToken(req, res) });
});
// Protect all state-changing routes
app.use(doubleCsrfProtection);
app.post("/transfer", async (req, res) => {
await processTransfer(req.body);
res.json({ status: "ok" });
});
When a request arrives without a valid token, the middleware throws and you respond with a 403:
app.use((err, req, res, next) => {
if (err.code === "EBADCSRFTOKEN") {
return res.status(403).json({ error: "Invalid CSRF token" });
}
next(err);
});
Output:
POST /transfer (no x-csrf-token header)
HTTP/1.1 403 Forbidden
{"error":"Invalid CSRF token"}
Apply CSRF middleware after body and cookie parsing, and only to state-changing methods.
GET,HEAD, andOPTIONSshould be safe (no side effects), so the middleware ignores them by default.
SameSite cookies
Modern browsers offer a built-in, complementary defense: the SameSite cookie attribute. It tells the browser whether to send a cookie on cross-site requests.
| Value | Behavior |
|---|---|
Strict | Cookie never sent on any cross-site request, including top-level links. |
Lax | Sent on top-level GET navigations, blocked on cross-site POST/iframe. |
None | Always sent; requires Secure. Needed for legitimate cross-site use. |
Setting sameSite: "lax" (the modern browser default) already blocks the cross-site POST in the attack above:
app.use(
require("express-session")({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { sameSite: "lax", secure: true, httpOnly: true },
})
);
SameSite=Laxis a strong baseline but not a complete substitute for tokens: older browsers, certain GET-driven side effects, and same-site subdomain attacks can slip through. Use both — defense in depth.
Why bearer-token APIs are different
CSRF exploits ambient credentials — cookies the browser sends automatically. An API that authenticates with an Authorization: Bearer <token> header read from localStorage is not vulnerable to classic CSRF, because the browser never attaches that header on its own; the attacker’s page would have to read the token, which the same-origin policy forbids.
Cookie session -> browser sends cookie automatically -> CSRF risk -> need tokens/SameSite
Bearer in header -> app must add header explicitly -> no CSRF -> but watch XSS
This is why stateless JSON APIs consumed by mobile apps or SPAs using header-based auth typically skip CSRF middleware. The tradeoff: tokens in JavaScript-accessible storage are exposed to XSS, so harden those apps against script injection instead. If you store the token in an httpOnly cookie, you are back in cookie territory and must re-enable CSRF protection.
Best practices
- Use cookie-based CSRF protection only when you authenticate with cookies; bearer-token APIs do not need it.
- Set
SameSite=Lax(orStrictwhere UX allows) plusSecureandhttpOnlyon every session cookie. - Adopt the maintained
csrf-csrfdouble-submit pattern; do not use the deprecatedcsurf. - Apply CSRF middleware only to unsafe methods (
POST,PUT,PATCH,DELETE) and after body/cookie parsing. - Use the
__Host-cookie prefix to lock cookies to the exact host over HTTPS. - Keep your CSRF and cookie secrets in environment variables, never in source control.
- Combine CSRF tokens with
SameSitecookies rather than relying on either alone.