Skip to main content
Back to blog
March 26, 2026·Updated May 2, 2026|10 min read|Antoine Duno|Web Security

How to Secure Cookies in Node.js: HttpOnly, Secure, and SameSite Explained

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.

Antoine Duno

1,641 words

AD

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.


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; HttpOnly

Why 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; Secure

Why 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:

js
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:

ValueBehaviorProtection level
StrictCookie sent only for same-site requestsMaximum — may break OAuth flows
LaxCookie sent for top-level navigation (GET), not for embedded images/iframesGood default for most apps
NoneCookie sent with all cross-site requestsNo CSRF protection — requires Secure
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax

Why 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 CookieJWT in CookieJWT in localStorage
XSS riskLow (HttpOnly)Low (HttpOnly)High (JS-readable)
CSRF riskMitigated by SameSiteMitigated by SameSiteNo CSRF risk, but XSS risk
RevocationImmediate (delete server session)Requires blocklistRequires blocklist
ScalabilityRequires session storeStatelessStateless
SizeSmall (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-session

js
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.

js
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 });
});

js
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
  },
});

js
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));

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.

js
// 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.

js
// 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-session

Mistake 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:

js
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)

  1. 1Open your site in Chrome
  2. 2Press F12 → Application tab → Cookies → your domain
  3. 3Check each cookie for the HttpOnly, Secure, and SameSite columns

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

bash
curl -I -c /dev/null https://yourdomain.com/login

Look 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.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading