Authentication with Sessions and Cookies

· 11 min read · Updated March 21, 2026 · intermediate
javascript node security authentication

HTTP has no memory. Every request arrives without knowing anything about the last. Sessions and cookies give your app a way to track users across requests.

What Are HTTP Cookies

A cookie is a small piece of data that the server sends to the client, and that the client stores and returns with subsequent requests. The server sets a cookie using the Set-Cookie HTTP header:

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400

On the client side, you can read non-HttpOnly cookies with document.cookie:

document.cookie = "theme=dark";
console.log(document.cookie);
// → "theme=dark"

Each cookie has several attributes that control its behavior:

HttpOnly — When set, the cookie cannot be read by JavaScript. This prevents XSS attacks from stealing session identifiers. Always set this for any cookie that holds sensitive data.

Secure — The cookie is only sent over HTTPS connections. In production, always combine this with HTTPS, otherwise the cookie travels in cleartext and can be intercepted.

SameSite — Controls when the browser includes the cookie in cross-site requests:

  • Strict — The cookie is only sent for same-site requests. Navigating from another site never sends it.
  • Lax (the default in modern browsers) — Sent for same-site requests and safe cross-site top-level navigations (GET requests). This is the practical middle ground.
  • None — Sent for all requests, but requires Secure to be set. Use this only when you genuinely need cross-site cookie sending (e.g., embedding in an iframe from another domain).

Domain — Specifies which hosts can receive the cookie. If omitted, the cookie is sent only to the exact host that set it (excluding subdomains). Setting Domain=example.com includes subdomains like api.example.com.

Path — Restricts which URL paths on the domain receive the cookie. Path=/ sends it to all paths; Path=/api limits it to /api and its children.

Max-Age — Number of seconds until the cookie expires. Max-Age=0 deletes the cookie immediately. If neither Max-Age nor Expires is set, the cookie is a session cookie—deleted when the browser closes.

Cookies are limited. Each cookie can hold at most 4 KB of data, and browsers limit you to roughly 20 cookies per domain. Do not store actual user data in cookies—store only an identifier, and keep the data on the server.

How Sessions Work

A session is a server-side data store associated with a specific user. When a user logs in, the server creates a session record containing their user data (user ID, roles, preferences). The server then sends the client a session identifier—typically a random string—in a cookie.

Client                    Server
  |                          |
  |-- POST /login ---------> |
  |   username, password    | Validate credentials
  |                          | Create session: { userId: 42 }
  |                          | Generate session ID: "a1b2c3..."
  |<-- Set-Cookie: session_id=a1b2c3...
  |
  |-- GET /dashboard ------> |
  |   Cookie: session_id=a1b2c3...
  |                          | Look up session by ID
  |                          | Get userId: 42
  |<-- 200 OK (dashboard)----|

The session ID must be unguessable. Use crypto.randomBytes(32) to generate 32 random bytes, then hex-encode them for a 64-character string. Never use Math.random() for security-sensitive values.

const { randomBytes } = require("crypto");

const sessionId = randomBytes(32).toString("hex");
// "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890"

On every request, the server reads the session ID from the cookie, looks up the corresponding session in its store, and makes the session data available to the request handler.

Setting Up Express-Session

The express-session middleware handles session management in Express applications.

npm install express-session
const express = require("express");
const session = require("express-session");
const { randomBytes } = require("crypto");

const app = express();

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    genid: () => randomBytes(32).toString("hex"), // use crypto.randomBytes
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: false, // set to true in production with HTTPS
      sameSite: "lax",
      maxAge: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
    },
  })
);

By default, express-session uses the uid-safe library to generate IDs. To use crypto.randomBytes() instead, pass a genid function.

secret is required. It is used to sign the session ID cookie and prevents tampering. Generate a long random string (at least 32 characters) and never commit it to source control. Use environment variables.

resave: false prevents resaving sessions that were not modified during the request. Leave this as false unless you have a specific reason to change it.

saveUninitialized: false prevents creating a session for every single request before the user has consented. Set this to false for GDPR compliance—if you do not need a session until the user logs in, do not create one for anonymous visitors.

cookie options apply the cookie attributes described above. Set secure: true when your application runs behind HTTPS.

Accessing and Modifying Sessions

Inside route handlers, session data lives on req.session:

app.post("/login", (req, res) => {
  const { username, password } = req.body;

  const user = validateUser(username, password);
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  // Store user ID in session
  req.session.userId = user.id;
  req.session.username = user.username;

  res.json({ message: "Logged in", user: { id: user.id, username: user.username } });
});

app.get("/me", (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: "Not authenticated" });
  }
  res.json({ userId: req.session.userId, username: req.session.username });
});

Redis Store for Production

express-session stores sessions in memory by default. Great for local dev, useless in production—sessions vanish on restart and don’t scale past one server. In production, use a distributed session store like Redis.

npm install connect-redis redis
# .env
SESSION_SECRET=your-long-random-secret-at-least-32-chars
REDIS_URL=redis://localhost:6379
const RedisStore = require("connect-redis").default;
const { createClient } = require("redis");

const redisClient = createClient({ url: process.env.REDIS_URL });

redisClient.on("error", (err) => console.error("Redis Client Error", err));
redisClient.on("connect", () => console.log("Redis connected"));

await redisClient.connect();

const app = express();

app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 24 * 60 * 60 * 1000, // 24 hours in milliseconds
    },
  })
);

connect-redis uses the Redis client to store sessions with a TTL. Sessions expire automatically based on cookie.maxAge.

Session Security

Three main attacks target sessions: fixation, hijacking, and CSRF.

Session Fixation

In a session fixation attack, the attacker visits your site, gets a session ID, and then tricks a victim into logging in with that same session ID. After the victim authenticates, the attacker knows the session ID and can use it to impersonate the victim.

The defense is to call req.session.regenerate() immediately after a successful login. This destroys the old session and creates a new one with a fresh ID.

app.post("/login", (req, res) => {
  const user = validateUser(req.body.username, req.body.password);
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  // Destroy old session and create a new one
  req.session.regenerate((err) => {
    if (err) {
      return res.status(500).json({ error: "Session error" });
    }

    req.session.userId = user.id;
    req.session.username = user.username;

    req.session.save((saveErr) => {
      if (saveErr) {
        return res.status(500).json({ error: "Failed to save session" });
      }
      res.json({ message: "Logged in" });
    });
  });
});

regenerate is asynchronous and requires a callback (or you can use the promise API if your version of express-session supports it). Always call it inside the callback before setting any session data.

Session Hijacking

If an attacker obtains a valid session ID, they can impersonate the user. Two defenses reduce this risk substantially:

  • HttpOnly: true — Prevents JavaScript (and therefore XSS attacks) from reading the cookie.
  • Secure: true — Ensures the cookie is only transmitted over encrypted HTTPS connections, so it cannot be intercepted on the network.

These two attributes alone block the most common session theft vectors. In addition, use HTTPS everywhere in production and set trust proxy in Express if behind a reverse proxy:

app.set("trust proxy", 1);

CSRF Attacks

Cross-Site Request Forgery exploits the fact that browsers send cookies automatically with every request. If a user is logged in and visits a malicious site, that site can trick the browser into making authenticated requests to your application.

SameSite=Lax provides baseline CSRF protection by preventing cookies from being sent on cross-site POST requests. For older browsers that do not support SameSite, or for applications with more complex requirements, add explicit CSRF tokens:

const csrf = require("csurf");
const csrfProtection = csrf({ cookie: true });

app.use(csrfProtection);

app.get("/form", (req, res) => {
  // Pass the token to the template
  res.render("form", { csrfToken: req.csrfToken() });
});

In an HTML form, include the token in a hidden field or header:

<form method="POST" action="/submit">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}" />
  <!-- other fields -->
</form>

Logging Out

To log a user out, destroy the session and clear the cookie:

app.post("/logout", (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: "Could not log out" });
    }
    res.clearCookie("connect.sid", {
      path: "/",
      httpOnly: true,
      sameSite: "lax",
      secure: true,
    });
    res.json({ message: "Logged out" });
  });
});

req.session.destroy() removes the session from the store. res.clearCookie("connect.sid", ...) removes the cookie from the client. The cookie name defaults to connect.sid; check what your name option is set to if you customized it.

JWT vs Sessions

Both JWT and sessions solve the same problem—stateless vs stateful authentication—but they work differently.

Sessions store user data on the server and send the client only a session ID in a cookie. The server looks up the session on each request. This keeps the data private (the client never sees it), limits the data size to the cookie’s 4 KB, and makes it easy to revoke sessions (delete from the store). Sessions require a session store (memory or Redis) and work best when you need server-side control over sessions.

JWT encodes user data directly into a signed token that the client stores (in localStorage or a cookie) and sends with every request. The server validates the signature but does not need to look anything up. This is stateless and scales horizontally without shared storage, but revoking a token before it expires requires a blocklist or short expiration times. JWT puts data in the client—visible but signed, and capped in size.

Choose sessions when:

  • You need to revoke sessions immediately (ban a user, force logout on all devices)
  • You want to keep session data private from the client
  • You are building a traditional server-rendered application

Choose JWT when:

  • You need stateless authentication across multiple services or domains
  • You want to avoid a session store and can accept token expiration as the revocation mechanism
  • You are building an API consumed by mobile apps or SPAs that manage their own token storage

For most server-rendered web applications, sessions are the simpler and more secure default.

Remember Me Pattern

“Remember Me” lets users stay logged in across browser sessions. The key is to separate the session cookie (short-lived, deleted on browser close) from a persistent authentication token (long-lived, survives browser restarts).

Do not extend the session cookie’s maxAge to months—this is insecure because a stolen session ID remains valid for a long time. Instead, use a separate token system:

const { randomBytes, createHash } = require("crypto");

// When user checks "Remember Me" during login
app.post("/login", (req, res) => {
  const user = validateUser(req.body.username, req.body.password);
  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  req.session.regenerate(() => {
    req.session.userId = user.id;

    if (req.body.rememberMe) {
      // Generate a 16-byte random token, hex-encoded = 32 characters
      const token = randomBytes(16).toString("hex");
      // Store a SHA-256 hash of the token in the database
      const tokenHash = createHash("sha256").update(token).digest("hex");

      storeRememberToken(user.id, tokenHash, {
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
      });

      // Send the raw token in a persistent, HttpOnly, Secure cookie
      res.cookie("remember_me", token, {
        httpOnly: true,
        secure: true,
        sameSite: "lax",
        maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days in ms
      });
    }

    req.session.save(() => res.json({ message: "Logged in" }));
  });
});

Store only the hash of the token in the database, never the raw token. If an attacker dumps the database, they cannot forge tokens because they do not know the raw value. When a request arrives with a remember_me cookie, hash the incoming token and compare it to the stored hash.

For a high-security application, consider using a signed JWT (with a long expiration) as the remember-me token instead of a raw random bytes approach. This lets you verify the token without a database lookup, though you lose the ability to revoke it without a blocklist.

GDPR and similar regulations require that you obtain meaningful consent before storing non-essential cookies. Sessions can be considered essential (they are necessary for the site to function), but express-session creates a session and sets a cookie for every single request if saveUninitialized is not set to false.

Set saveUninitialized: false to delay session creation until you have an actual need:

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false, // only create sessions when explicitly set
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
    },
  })
);

With this setting, req.session is an empty object until you set a property on it, and no Set-Cookie header is sent. Once you write req.session.userId = 42, the middleware saves the session and sends the cookie.

For a cookie consent banner, defer all non-essential cookie setting (analytics, advertising, third-party scripts) until the user accepts. Session cookies that are strictly necessary for the application to function are exempt from consent requirements in most jurisdictions, but record which cookies you consider essential and why.

See Also