Skip to content
Express.js ex security 4 min read

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, and OPTIONS should 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.

ValueBehavior
StrictCookie never sent on any cross-site request, including top-level links.
LaxSent on top-level GET navigations, blocked on cross-site POST/iframe.
NoneAlways 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=Lax is 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 (or Strict where UX allows) plus Secure and httpOnly on every session cookie.
  • Adopt the maintained csrf-csrf double-submit pattern; do not use the deprecated csurf.
  • 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 SameSite cookies rather than relying on either alone.
Last updated June 14, 2026
Was this helpful?