jsguides

Authentication Sessions: Secure Login with Cookies and Express-Session

HTTP has no memory. Every request arrives without knowing anything about the last. Authentication sessions, built on cookies and server-side storage, give your app a way to track users across requests and maintain login state securely.

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

The Set-Cookie header shown above sets a session identifier with security attributes applied. Browsers store the cookie and send it back automatically on every subsequent request to the same origin, which is how the server recognizes returning users.

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

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

This JavaScript API only sees non-HttpOnly cookies. Server-set session identifiers tagged with HttpOnly are invisible to document.cookie, which prevents cross-site scripting attacks from reading sensitive tokens.

Each cookie has several attributes that control its behavior:

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

Secure means 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 sets the 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. An attacker who can predict session IDs can hijack any active session on the site. Use crypto.randomBytes(32) to generate 32 random bytes, then hex-encode them for a 64-character string. Never use Math.random(), Date.now(), or any other predictable source 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

The middleware attaches a req.session object to every request. Before the session is created, this object is empty. Once you set a property on it, Express-Session generates a session ID, stores the data, and sends a Set-Cookie header automatically.

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 of the default generator, 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. The cookie name defaults to connect.sid; you can change it with the name option if you want to avoid fingerprinting that reveals you are using Express.

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

Any property you set on req.session is serialized and stored. This works for simple values like strings and numbers, but storing large objects or class instances can cause serialization issues. Stick to plain data like user IDs and roles.

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

The Redis client handles connection pooling and reconnection automatically, so you do not need to manage the connection lifecycle yourself. In production, set REDIS_URL to your managed Redis instance and make sure the connection uses TLS if your Redis provider requires it. The connect-redis package stores each session as a Redis key with a TTL matching cookie.maxAge, so sessions expire automatically without cleanup jobs:

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>

For APIs that do not render HTML forms, send the CSRF token in a custom header like X-CSRF-Token instead. The csurf middleware checks both form fields and headers, so this works for SPAs as long as you include the token in every mutating request.

Logging out

To log a user out, you need to remove both the server-side session data and the client-side cookie. Destroying only one of them leaves an inconsistent state: the user looks logged out but their session still occupies storage, or the cookie still gets sent on every request:

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, 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