Antoine Duno
Founder of ZeriFlow · 10 years fullstack engineering · About the author
Key Takeaways
- Insecure cookies are one of the most common and exploitable misconfigurations in Node.js web applications. Understanding and correctly setting HttpOnly, Secure, and SameSite is non-negotiable for any session-handling application.
- Includes copy-paste code examples and step-by-step instructions.
- Free automated scan available to verify your implementation.
How to Secure Cookies in Node.js: HttpOnly, Secure, and SameSite Explained
Secure cookies in Node.js are not the default. When you set a cookie with Express, Fastify, or Koa without explicitly configuring security flags, you get a cookie that JavaScript can read, that travels over HTTP, and that is sent on every cross-site request. Each of those behaviors is a vulnerability.
This guide explains what each flag does, shows you how to configure cookies correctly in the three most popular Node.js frameworks, compares session cookies to JWTs, and shows you how to audit your cookies in production.
The Three Essential Cookie Security Flags
HttpOnly
When a cookie has the HttpOnly flag, it cannot be accessed by JavaScript. document.cookie does not include it. fetch() cannot read it. No client-side code can touch it.
Set-Cookie: session=abc123; HttpOnlyWhy this matters: The most common way attackers steal session tokens is via XSS. If an attacker injects JavaScript into your page — through a comment, a URL parameter, a third-party script — the first thing they do is read document.cookie and send all cookies to their server. HttpOnly makes that attack worthless for any cookie carrying it.
When to use it: Every session cookie, every authentication token cookie, every cookie that a user''s browser-side JavaScript does not need to read. In practice, almost every sensitive cookie should be HttpOnly.
When not to use it: Cookies that your client-side JavaScript legitimately needs to read. A darkMode=true preference cookie can be non-HttpOnly. A session ID cannot.
Secure
The Secure flag tells the browser to only send the cookie over HTTPS connections. It will never be sent over plain HTTP.
Set-Cookie: session=abc123; HttpOnly; SecureWhy this matters: Without Secure, if a user visits your site over HTTP (even once — perhaps by typing your domain without https://), the session cookie is transmitted in plaintext. Anyone on the same network can read it. This is particularly dangerous on public Wi-Fi.
When to use it: All production cookies. If your application runs on HTTPS (it should), every sensitive cookie needs Secure.
Development note: The Secure flag prevents cookies from being set on http://localhost in most browsers. Use a conditional in your application code:
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
// ...
};SameSite
SameSite controls when cookies are included in cross-site requests. It has three values:
| Value | Behavior | Protection level |
|---|---|---|
Strict | Cookie sent only for same-site requests | Maximum — may break OAuth flows |
Lax | Cookie sent for top-level navigation (GET), not for embedded images/iframes | Good default for most apps |
None | Cookie sent with all cross-site requests | No CSRF protection — requires Secure |
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=LaxWhy this matters: Without SameSite, your cookies are sent on every request to your domain, including requests initiated from malicious third-party sites. This enables Cross-Site Request Forgery (CSRF) attacks where an attacker tricks a logged-in user into making requests to your API from a malicious page.
`Lax` vs `Strict` for session cookies:
- Use Lax for most applications. It blocks CSRF while allowing users to land on your site authenticated after clicking a link from an email or external page.
- Use Strict for high-security applications (banking, admin panels) where you can tolerate users needing to log in again after clicking external links.
`SameSite=None` is required for legitimate cross-site cookie use cases (third-party embeds, OAuth flows initiated from iframes). It must always be paired with Secure.
Session Cookies vs JWT: Which Is More Secure?
This debate is often framed incorrectly. Both can be secure or insecure depending on implementation.
| Session Cookie | JWT in Cookie | JWT in localStorage | |
|---|---|---|---|
| XSS risk | Low (HttpOnly) | Low (HttpOnly) | High (JS-readable) |
| CSRF risk | Mitigated by SameSite | Mitigated by SameSite | No CSRF risk, but XSS risk |
| Revocation | Immediate (delete server session) | Requires blocklist | Requires blocklist |
| Scalability | Requires session store | Stateless | Stateless |
| Size | Small (random ID) | Larger (JWT payload) |
The verdict: A session cookie with HttpOnly; Secure; SameSite=Lax is the most secure option for most applications. Storing a JWT in localStorage is the least secure — JavaScript can always read it, so XSS immediately compromises authentication.
If you use JWTs, store them in HttpOnly cookies, not in localStorage.
Express: Secure Cookie Configuration
express-session
const express = require("express");
const session = require("express-session");
const app = express();
app.use(
session({
secret: process.env.SESSION_SECRET, // Long, random string — use an env var
name: "__Host-session", // See note on __Host- prefix below
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days in milliseconds
path: "/",
},
})
);The `__Host-` prefix: Prefixing a cookie name with __Host- tells the browser to enforce that the cookie:
1. Was set with Secure
2. Was set without a Domain attribute (scoped to the exact host, not subdomains)
3. Was set with Path=/
This is a defense-in-depth measure against subdomain hijacking. If an attacker controls a subdomain, they cannot set a __Host- cookie that will be sent to your main domain.
cookie-parser (for non-session cookies)
const cookieParser = require("cookie-parser");
const app = express();
app.use(cookieParser());
app.get("/set-preference", (req, res) => {
// Non-sensitive preference — no HttpOnly needed
res.cookie("theme", "dark", {
maxAge: 1000 * 60 * 60 * 24 * 365,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
});
// Sensitive token — full flags required
res.cookie("__Host-auth", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24,
path: "/",
});
res.json({ success: true });
});Fastify: Secure Cookie Configuration
const fastify = require("fastify")({ logger: true });
const fastifyCookie = require("@fastify/cookie");
const fastifySession = require("@fastify/session");
fastify.register(fastifyCookie);
fastify.register(fastifySession, {
secret: process.env.SESSION_SECRET,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax",
maxAge: 86400000, // 1 day in ms
},
});Koa: Secure Cookie Configuration
const Koa = require("koa");
const session = require("koa-session");
const app = new Koa();
app.keys = [process.env.SESSION_SECRET]; // Required for signed cookies
const SESSION_CONFIG = {
key: "__Host-koa.sess",
maxAge: 86400000,
autoCommit: true,
overwrite: true,
httpOnly: true,
signed: true,
rolling: false,
renew: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
};
app.use(session(SESSION_CONFIG, app));Common Cookie Security Mistakes
Mistake 1: Not setting secure: true in production
Many tutorials set secure: false for local development but forget to change it for production. Always use secure: process.env.NODE_ENV === "production".
Mistake 2: SameSite=None without Secure
SameSite=None is only valid when paired with Secure. Modern browsers reject SameSite=None cookies that are not also Secure.
// Wrong
{ sameSite: "none" }
// Correct
{ sameSite: "none", secure: true }Mistake 3: Storing sensitive data in cookies instead of a session store
Cookies have a 4KB size limit and are transmitted with every request. If you need to store user data, store an opaque session ID in the cookie and keep the data in Redis or your database.
// Wrong — storing JWT payload or user data directly
res.cookie("userData", JSON.stringify({ userId: 123, role: "admin" }), ...);
// Correct — store a session ID that maps to server-side session data
req.session.userId = 123;
req.session.role = "admin";
// Session ID is set in cookie automatically by express-sessionMistake 4: Not setting an expiry
Cookies without maxAge or expires are session cookies — they expire when the browser closes. This sounds conservative, but in practice browsers restore sessions across restarts, so "session cookies" can persist indefinitely on a user''s device. Always set a reasonable expiry.
Mistake 5: Broad Domain attribute
Setting Domain=.yourdomain.com makes the cookie available to all subdomains, including ones you might not control or that might be compromised. Prefer the __Host- prefix pattern which scopes cookies to the exact origin.
Mistake 6: Not invalidating cookies on logout
On logout, the server-side session must be destroyed AND the cookie must be cleared:
app.post("/logout", (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: "Logout failed" });
}
// Clear the cookie by setting it with maxAge=0
res.clearCookie("__Host-session", {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
res.json({ success: true });
});
});How to Audit Your Cookies
Browser DevTools (Application tab)
- 1Open your site in Chrome
- 2Press
F12→ Application tab → Cookies → your domain - 3Check each cookie for the
HttpOnly,Secure, andSameSitecolumns
Any cookie that has authentication or session data and is missing any of these three should be fixed immediately.
Automated scan
Run a free scan at zeriflow.com/free-scan. ZeriFlow checks every cookie by name, flags any missing HttpOnly, Secure, or SameSite flags, and includes them in your /100 security score. It takes 60 seconds and requires no signup.
curl
curl -I -c /dev/null https://yourdomain.com/loginLook for Set-Cookie headers in the response and verify each one has the correct flags.
Summary
Securing cookies in Node.js requires explicitly setting HttpOnly, Secure, and SameSite — none of these are defaults in Express, Fastify, or Koa. Use HttpOnly on every session cookie, Secure in production, and SameSite=Lax as the default (upgrading to Strict for high-security pages). Avoid storing sensitive data in cookie values, always expire cookies explicitly, and invalidate both the server session and the client cookie on logout. Audit your cookies in browser DevTools or with a free ZeriFlow scan to verify your production configuration is correct.