A lightweight, stateless pre-session CSRF protection middleware for Express, implementing OWASP's Signed Double-Submit Cookie pattern — but without binding the token to a session. Designed for unauthenticated mutating routes; typically the login form.
cookie-csrf is a companion to small-csrf, not a replacement. Load both:
| Library | Binds token to | Use on |
|---|---|---|
small-csrf |
req.session.id |
Authenticated routes (the default) |
cookie-csrf |
a self-minted signed cookie nonce | Unauthenticated routes only (e.g. /login) |
** Security caveat **
cookie-csrfis weaker than session-bound CSRF. Its HMAC signature only defends against cookie injection; it does not bind the token to a user identity. That is acceptable for the pre-auth case (whose real threat, login-CSRF, is stopped by the browser cookie jar +SameSite), but it means: use it only on unauthenticated routes, and rotate tosmall-csrfthe moment the user logs in.
cookie-csrf implements the OWASP Signed Double-Submit Cookie pattern, but mints a signed, stateless nonce cookie instead of reading req.session. This means GET /login doesn't involved the session store (avoiding a session db write).
- No session dependency — safe to use with
saveUninitialized: false - Constant-time token comparison to prevent timing attacks
- Distinct request accessor, cookie key, param, and headers to avoid collisions with
small-csrf - Zero runtime dependencies, ESM, Node ≥ 20
npm install cookie-csrfThe intended setup runs both middlewares — cookie-csrf on the pre-auth login flow (route-level), small-csrf on the authenticated area:
// npm install express express-session cookie-parser cookie-csrf small-csrf
import express from "express";
import session from "express-session";
import cookieParser from "cookie-parser";
import cookieCsrfProtection from "cookie-csrf";
import csrfProtection from "small-csrf";
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(
session({
secret: "your-session-secret",
resave: false,
saveUninitialized: false, // no session row for anonymous visitors
cookie: { secure: process.env.NODE_ENV === "production" },
}),
);
// Pre-auth CSRF (route-level, never global, never touches the session)
const cookieCsrf = cookieCsrfProtection({
secret: "at-least-32-characters-long-pre-secret",
});
// Authenticated-area CSRF (session-bound)
const sessionCsrf = csrfProtection({
secret: "a-different-32-plus-char-session-secret",
});
// --- Login flow: cookie-csrf, uses preCsrfToken() / _csrf_pre ---
app.get("/login", cookieCsrf, (req, res) => {
res.send(`
<form action="/login" method="POST">
<input type="hidden" name="_csrf_pre" value="${req.preCsrfToken()}">
<input name="username"><input type="password" name="password">
<button type="submit">Login</button>
</form>
`);
});
app.post("/login", cookieCsrf, (req, res) => {
// ...authenticate...
if (!valid) {
// Bad password: re-render the form. preCsrfToken() rotates the cookie and
// returns a matching token, so the re-rendered form's next submit succeeds.
return res.status(401).send(`
<p>Invalid username or password.</p>
<form action="/login" method="POST">
<input type="hidden" name="_csrf_pre" value="${req.preCsrfToken()}">
<input name="username"><input type="password" name="password">
<button type="submit">Login</button>
</form>
`);
}
res.clearCookie("csrf_pre_token"); // retire the pre-auth token
req.session.user = { name: req.body.username }; // now a real session exists
req.session.save(() => res.redirect("/dashboard"));
});
// --- Authenticated area: small-csrf, uses csrfToken() / _csrf ---
app.get("/dashboard", sessionCsrf, (req, res) => {
res.send(`csrf token: ${req.csrfToken()}`);
});
// One handler catches CSRF errors from BOTH (same error code)
app.use((err, req, res, next) => {
if (err.code === "EBADCSRFTOKEN") {
return res.status(403).send("Invalid CSRF token. Please try again.");
}
next(err);
});
app.listen(3000);small-csrf binds each token to req.session.id. For that ID to be stable between GET /login (render) and POST /login (submit) under saveUninitialized: false, the app must dirty the session on the GET — the req.session.initialized = true workaround.
That write defeats the point of saveUninitialized: false: every unauthenticated visitor gets a persisted session row, so an attacker (or a crawler) can hammer GET /login and exhaust the session store / rate limits before anyone authenticates.
cookie-csrf allows the session dependency for the pre-auth route to be removed, so GET /login creates no session and sends no connect.sid.
- The barrier that actually stops CSRF is the browser cookie jar +
SameSite: an attacker can't read the victim's HttpOnly cookie and can't plant one cross-site, so they can't put a matching token in a forged form. - The HMAC signature only buys defense against cookie injection (a sibling subdomain / MITM writing a cookie value the attacker knows). It does not bind the token to a user identity.
- This is fine for the pre-auth case, whose real threat is login-CSRF (covered by the cookie-jar +
SameSitebarrier). It is weaker thansmall-csrf— so use it only on unauthenticated routes and rotate on login.
A safe request (GET/HEAD/OPTIONS) reuses the existing csrf_pre_token cookie if the browser already presents one and it's well-formed (HMAC matches), rather than minting a fresh nonce on every single request. A fresh nonce is only minted when there's no cookie yet, or the presented one is malformed.
This matters because modern Chromium-based browsers (Chrome, Edge, Brave,Vivaldi, Opera) routinely fire extra, invisible, credentialed GETs to a URL the user is merely likely to visit next (prefetch/prerender), independent of "View Source", retried connections, or a second tab open on the same form.
Without reuse, every one of those incidental GETs would silently rotate the cookie out from under the page the user is actually looking at, orphaning its embedded token and 403ing the next real submit — this used to be documented here as the "multi-tab caveat"; it's now fixed for all of these cases, not just the two-tabs one.
Reuse has no effect on the security model: isWellFormed only checks that the cookie's HMAC matches its random value (a shape check), never that it's the right token for the current visitor. The check that actually matters —recomputing and comparing the HMAC, then the double-submit comparison — still runs in full on every state-changing request via verifyToken(); a forged cookie that happens to be well-formed is still rejected there.
Sliding maxAge: because reuse re-issues the cookie (same value, fresh Set-Cookie), cookie.maxAge measures idle time since the last safe request, not time since the token was first minted. A tab left open with
periodic incidental GETs (prefetch, polling, etc.) keeps its token alive indefinitely rather than expiring on a hard 1-hour cap from mint. The token still only ever dies by: expiring after maxAge of genuine inactivity, or
being consumed and rotated by a verified POST/PUT/PATCH/DELETE.
- On a safe request (GET/HEAD/OPTIONS): if the browser presents an existing, well-formed
csrf_pre_tokencookie, it's reused (see [Token reuse on safe requests (#token-reuse-on-safe-requests-and-the-multi-tab--prefetch-caveat-it-fixes)); otherwise a cryptographically strong random nonce is generated and HMAC-signed. Either way the token is:- set as an HTTP-only cookie (
csrf_pre_tokenby default), and - exposed via
req.preCsrfToken()for inclusion in forms or AJAX.
- set as an HTTP-only cookie (
- On a state-changing request (POST/PUT/PATCH/DELETE) the middleware:
- recomputes the HMAC from the cookie's random value and checks it (rejects cookie injection),
- checks the submitted token equals the cookie token (double-submit),
- both comparisons are constant-time.
- After verification passes,
req.preCsrfToken()rotates the token: the first time a handler calls it, a fresh nonce is minted, the cookie is refreshed, and the matching token is returned — so re-rendering a form (e.g. the login form after a bad password) "just works". Rotation is lazy: a handler that verifies and then redirects (a successful login) never calls the accessor, so no cookie is set.
Creates and returns the CSRF middleware function.
| Option | Type | Default | Description |
|---|---|---|---|
secret |
String | required | Secret key used for HMAC signature (must be at least 32 characters) |
cookie.key |
String | "csrf_pre_token" |
Name of the cookie storing the CSRF token |
cookie.path |
String | "/" |
Path for the CSRF cookie |
cookie.httpOnly |
Boolean | true |
Whether the cookie is HTTP only |
cookie.sameSite |
String | "strict" |
SameSite policy for the cookie ("strict", "lax", or "none") |
cookie.secure |
Boolean | true |
Whether the cookie requires HTTPS |
cookie.maxAge |
Number | 3600000 |
Max age of the cookie in milliseconds (1 hour default). Reissued (sliding) on every safe request that reuses the token — see Token reuse |
ignoreMethods |
Array | ["GET", "HEAD", "OPTIONS"] |
HTTP methods that don't need CSRF validation |
csrfParam |
String | "_csrf_pre" |
Name of the parameter containing the CSRF token in requests |
value |
Function | reads body[csrfParam] → x-pre-csrf-token → x-xsrf-pre-token |
Custom extractor for the submitted token (never the query string) |
Function added to the request object that returns the current pre-auth CSRF token. Use this to include the token in your login form or AJAX requests.
The contract is uniform across methods: req.preCsrfToken() always returns a token that matches the csrf_pre_token cookie set on the response. On safe methods (GET/HEAD/OPTIONS) the cookie is set unconditionally. On verified state-changing methods (POST/PUT/PATCH/DELETE) calling the accessor rotates the token — minting a fresh nonce and refreshing the cookie — which is exactly what you want when re-rendering a form after a validation failure. The token is effectively single-use: each accepted submit that re-renders rotates the nonce.
Note the deliberate renames vs
small-csrfso the two never collide when loaded together:
Surface small-csrf cookie-csrf Request accessor req.csrfToken()req.preCsrfToken()Cookie key csrf_tokencsrf_pre_tokenForm param _csrf_csrf_preHeaders accepted x-csrf-token,x-xsrf-tokenx-pre-csrf-token,x-xsrf-pre-tokenError codeEBADCSRFTOKENEBADCSRFTOKEN(same — one handler catches both)
For maximum security:
- Use HTTPS in production environments
- Use a cryptographically strong secret (at least 32 characters)
- Rotate on login:
res.clearCookie("csrf_pre_token")and switch the authenticated area tosmall-csrf - Set appropriate
sameSiteandsecurecookie options (strict + secure: true)
Uses the built-in Node test runner - available from Node 20
npm test to run
To run a local demo of the cookie-csrf + small-csrf combo from a cloned repo:
cd example
npm install
npm startThen visit http://localhost:3000. Observe that GET /login sets csrf_pre_token but no connect.sid; after login the pre-auth cookie is cleared and the dashboard is protected by small-csrf's csrf_token.
This is basically a copy of small-csrf with the session binding removed. A valid alternative approach might have been to add a session-less mode to small-csrf, but since it's so small (a single 130 line file) I judged that it was better to have it very clear in my projects which csrf was being applied where, and eliminate the chance of small-csrf being used in a weaker mode without the developer noticing.
small-csrf is basically a JS implementation of the OWASP CSRF Cheat sheet, so apart from not binding to the session, that's also the intention of this library.
AI tools were used in this project.
- 0.1.0 - initial
- 0.2.0 -
req.preCsrfToken()now rotates the token on verified state-changing requests (lazy), giving a uniform "always matches the cookie" contract so re-rendering a form after a validation failure works without reflecting the submitted token - 0.3.0 - Safe requests (GET/HEAD/OPTIONS) reuse an existing well-formed
csrf_pre_tokencookie instead of unconditionally minting a fresh one.