Sessions and Cookies

A session cookie should carry only an opaque ID; the name, role and everything else stay in a server-side store.

Syntaxres.setHeader('Set-Cookie', `sid=${id}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600`)

HTTP has no memory. Each request arrives on its own with nothing to say it came from the same person as the last one. A cookie closes that gap. The server sends a Set-Cookie header once, and the browser repeats the value in a Cookie header on every later request to that site.

Put a long random ID in the cookie and nothing else. The user's name, email and role live on the server in a session store keyed by that ID. The cookie is the cloakroom ticket, not the coat.

The attributes that matter

  • HttpOnly — page scripts cannot read the cookie, so a cross-site scripting bug cannot steal it.
  • Secure — the browser sends it only over HTTPS.
  • SameSite=Lax — it is not sent on cross-site form posts, which blocks most cross-site request forgery. Strict is tighter, and None is only allowed together with Secure.
  • Max-Age — lifetime in seconds. Without it the cookie dies when the browser closes.

Where the store actually lives

A Map inside your process works until the process restarts, and breaks the moment a second copy of your process runs: that copy has never seen the ID. Keep sessions in Redis or a database table so every copy reads the same data.

On login

Issue a brand-new ID straight after the password check. Reusing the pre-login ID lets an attacker who planted a known ID ride into the account, which is called session fixation.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Keeping a signed-in user signed in across page loads.
  • Logging a user out everywhere at once by deleting their store rows.
  • Moving an app off in-memory sessions before running a second copy of the process.
  • Hardening an existing cookie against theft by a cross-site scripting bug.

More examples

express-session with a Redis store

The production shape. The store is the part that matters: swap Redis in and a restart or a second copy of the process no longer logs everybody out. Watch the units — the raw cookie attribute Max-Age is in seconds, while express-session's maxAge option is in milliseconds, and mixing them up gives sessions that expire a thousand times too soon.

Example · javascript
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();

// This one middleware is the shorthand for the whole cycle the runnable
// example does by hand: read the Cookie header, look the ID up in the
// store, hang the data on req.session, and write Set-Cookie back.
app.use(session({
  store: new RedisStore({ client }),
  secret: process.env.SESSION_SECRET, // signs the cookie so the ID cannot be edited
  name: 'sid',                        // default is 'connect.sid'
  resave: false,                      // do not rewrite an unchanged session
  saveUninitialized: false,           // no store row until you put something in it
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 1000 * 60 * 60 * 24 * 7,  // MILLISECONDS here, unlike Max-Age
  },
}));

Login, logout, and session fixation

Both routes are callback-shaped because that is the API express-session gives. The rule behind them: a session ends on the server, not in the browser. Use this pair whenever a login form exists, and reach for regenerate again after any privilege change, such as a user becoming an admin.

Example · javascript
app.post('/login', async (req, res) => {
  const userId = await checkPassword(req.body.email, req.body.password);
  if (!userId) return res.status(401).json({ error: 'invalid credentials' });

  // Throw the old session ID away and mint a new one. Without this, an ID
  // the attacker planted before login keeps working after it.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: 'session error' });
    req.session.userId = userId;
    // save() before replying, so the store has the row before the
    // browser can send its next request with the new cookie.
    req.session.save((err2) => {
      if (err2) return res.status(500).json({ error: 'session error' });
      res.json({ ok: true });
    });
  });
});

app.post('/logout', (req, res) => {
  // destroy() removes the server-side row. Deleting the cookie alone
  // would leave a working session for anyone who copied it.
  req.session.destroy(() => {
    res.clearCookie('sid');
    res.json({ ok: true });
  });
});

Deleting a cookie by hand

The reason a logout sometimes appears to do nothing. If the delete is sent with Path=/account while the cookie was set with Path=/, the browser keeps the original and the session carries on. Useful anywhere you are not using a session framework and have to clear cookies on a plain node:http response.

Example · javascript
// A cookie is deleted by overwriting it with an expired one. The browser
// matches on name, Domain and Path, so those must be IDENTICAL to the
// attributes you set it with, or you create a second cookie instead.
res.setHeader('Set-Cookie',
  'sid=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0');

// Two cookies on one response need an ARRAY. A second setHeader call
// would replace the first instead of adding to it.
res.setHeader('Set-Cookie', [
  'sid=; Path=/; Max-Age=0',
  'csrf=; Path=/; Max-Age=0',
]);

Discussion

  • Be the first to comment on this lesson.