Express Complete Tutorial Part 4: Manage Sessions

A user signs in, then expects the next request to keep them signed in. Express sessions let an app keep login data on the server while the browser sends a session ID with later requests.

I’ll walk through adding login and logout, then explain what to check before deploying sessions.

TL;DR

Use express-session to keep login data on the server and send the browser a signed session ID. Put the middleware before routes, regenerate the ID after valid credentials, and destroy the session when the user logs out.

  • Install with npm install express express-session.
  • Keep the session secret outside source control.
  • Replace MemoryStore before deploying multiple processes.

What is an Express session?

An Express session gives an app a way to preserve user-specific state between separate HTTP requests because HTTP does not retain application state.

The browser receives a signed ID cookie, while session data remains in the configured store. Signing detects tampering rather than encrypting the ID, so keep it out of URLs and logs because anyone who copies it could replay requests until the server invalidates the session.

PartWhere it livesJob
Session ID cookieBrowserIdentifies the session on a later request
Session recordConfigured server-side storeHolds values such as the authenticated user ID
req.sessionExpress requestLets a route read or change that request’s session data

MemoryStore keeps records in the Node process, letting this single-process test preserve login across requests, but a restart erases them and another app process cannot read them. Express warns it can retain memory, so use it only for local development. The Redis session guide covers shared storage.

How to add session login and logout in Express

The example adds a login route, a protected profile route, and logout. I chose a tiny form-encoded request and an in-process test so the sample shows the cookie continuing across requests without setting up a browser or external service.

Use Node.js 18 or newer for Express 5. In a project directory, initialize npm and install the current Express packages:

npm init -y
npm install express express-session

Save the following as session-demo.js. The fixed demo credentials only exercise route flow, and the local process generates a temporary secret when none is set. Replace the credential check with password-hash verification and configure SESSION_SECRET consistently across production instances.

const express = require('express');
const session = require('express-session');
const assert = require('node:assert/strict');
const { randomBytes } = require('node:crypto');

function createApp() {
  if (process.env.NODE_ENV === 'production' && !process.env.SESSION_SECRET) {
    throw new Error('SESSION_SECRET is required in production');
  }
  const app = express();
  app.use(express.urlencoded({ extended: false }));
  app.use(session({
    name: 'sid',
    secret: process.env.SESSION_SECRET || randomBytes(32).toString('hex'),
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
      maxAge: 15 * 60 * 1000
    }
  }));

  app.post('/login', (req, res, next) => {
    // Demonstration only. Replace with a real password-hash check.
    if (req.body.username !== 'sam' || req.body.password !== 'demo-only') {
      return res.status(401).send('Invalid credentials');
    }
    req.session.regenerate((error) => {
      if (error) return next(error);
      req.session.user = { id: 1, name: 'Sam' };
      req.session.save((saveError) => {
        if (saveError) return next(saveError);
        res.send('Signed in');
      });
    });
  });

  app.get('/profile', (req, res) => {
    if (!req.session.user) return res.status(401).send('Sign in required');
    res.send(`Hello, ${req.session.user.name}`);
  });

  app.post('/logout', (req, res, next) => {
    req.session.destroy((error) => {
      if (error) return next(error);
      res.clearCookie('sid');
      res.send('Signed out');
    });
  });
  return app;
}

async function test() {
  const server = createApp().listen(0, '127.0.0.1');
  await new Promise((resolve) => server.once('listening', resolve));
  const base = `http://127.0.0.1:${server.address().port}`;
  let cookie;
  try {
    const before = await fetch(`${base}/profile`);
    assert.equal(before.status, 401);
    console.log(`Before login: ${before.status} Sign in required`);

    const login = await fetch(`${base}/login`, {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: 'username=sam&password=demo-only',
      redirect: 'manual'
    });
    assert.equal(login.status, 200);
    cookie = login.headers.get('set-cookie').split(';')[0];
    console.log(`Login: ${login.status} Signed in`);

    const profile = await fetch(`${base}/profile`, { headers: { cookie } });
    assert.equal(profile.status, 200);
    assert.equal(await profile.text(), 'Hello, Sam');
    console.log(`Profile with session: ${profile.status} Hello, Sam`);

    const logout = await fetch(`${base}/logout`, {
      method: 'POST', headers: { cookie }, redirect: 'manual'
    });
    assert.equal(logout.status, 200);
    console.log(`Logout: ${logout.status} Signed out`);

    const after = await fetch(`${base}/profile`, { headers: { cookie } });
    assert.equal(after.status, 401);
    console.log(`After logout: ${after.status} Sign in required`);
    console.log('TEST PASS: login, protected route, and logout');
  } finally {
    await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  }
}

if (require.main === module) {
  test().catch((error) => {
    console.error(error);
    process.exitCode = 1;
  });
}

module.exports = createApp;

The test starts on an available local port, sends requests through the app, and closes its server in a finally block. Run the same file with Node:

node session-demo.js

I ran this test with Node.js 26.7.0. The app uses Express 5.3.0 and express-session 1.19.0. Each response matches the test’s expected status, while password verification and production storage remain outside this test.

The test client copies the cookie header manually, so it verifies session persistence between requests but not browser enforcement of cookie attributes. It also does not exercise a TLS-terminating proxy. After deployment, inspect the Set-Cookie response and confirm that the browser returns the cookie on a later HTTPS request.

Step 1: Register the middleware before routes

express-session adds req.session for later route handlers and manages its own cookie, so cookie-parser is not needed for that job. The app parses form data before mounting session middleware for its routes. With saveUninitialized: false, this example skips creating unchanged anonymous sessions.

resave: false avoids unnecessary writes when a session is unchanged, but confirm the selected store supports that behavior.

Step 2: Verify credentials, then regenerate and save

Verify credentials with a password hash before regenerating the session ID. On failure, return 401 without setting an authenticated user. After success, save the minimal session record before any response or redirect that depends on it, and pass store errors to Express with next(error).

Step 3: Check the session on protected routes

The /profile route uses session state to decide whether to return protected content. The test checks requests before login, with the session cookie, and after logout. Part 3 of this series covers how Express routes receive requests.

The demo keeps a display name in the session so its response is clear in the test output. A larger app can store the user ID and load current profile details from its database on this route. Then account changes can take effect without relying on a stale session copy.

Return 401 when no user is signed in and 403 when a signed-in user lacks permission.

app.get('/profile', (req, res) => {
  if (!req.session.user) return res.status(401).send('Sign in required');
  res.send(`Hello, ${req.session.user.name}`);
});
node session-demo.js
Before login: 401 Sign in required
Login: 200 Signed in
Profile with session: 200 Hello, Sam
Logout: 200 Signed out
After logout: 401 Sign in required
TEST PASS: login, protected route, and logout
Terminal output showing Express session login, protected profile, and logout test results
Express returns 401 before login and after logout, and serves the profile while the session is active.

Move the same check into authorization middleware when several routes share it. The test covers session handling, while login-page design and credential storage require separate code.

Step 4: Destroy the record on logout

After req.session.destroy() completes, clear the cookie with the same options used to set its scope. The test then sends the old cookie again and verifies a 401 response.

What to check before deploying sessions

Deployment choices depend on where HTTPS ends and how many app processes serve requests, because both affect cookie delivery and store access.

Setting or symptomWhat to verify
Session storeSelect a production-ready store shared by app instances. MemoryStore is for local development.
SecretLoad a high-entropy secret from deployment configuration and use a consistent value across instances.
Browser script accessSet httpOnly so scripts cannot read the cookie directly. It does not stop injected scripts from making requests as the user.
Cross-site requestsChoose sameSite for the login flow. Cross-site cookies need Secure and explicit CSRF defenses.
SymptomFirst check
Cookie missing over HTTPSEnable secure and configure trust proxy for the intended TLS-terminating proxy only.
Apps sharing a hostChoose a distinct cookie name and limit Domain and Path to the required requests.
Session store is unavailableFail closed on private routes rather than treating a missing record as an authenticated user.

These are individual reports, not prevalence evidence. Issue 983 discusses secure-cookie behavior, while issue 167 describes data missing across back-to-back requests.

Several requests from one browser can run at once, especially when a page loads API data. If two handlers change the same session field, one write can overwrite the other. resave: false avoids writing unchanged sessions but does not serialize conflicting application updates.

Choose a session timeout deliberately

Set the browser cookie’s expiry and the store record’s lifetime as separate policy decisions. The local sample uses a 15-minute cookie age.

Expiry situationWhat happensWhat to decide
Cookie expires firstThe browser stops sending its session ID, even if the store still has a record.Choose an idle timeout that fits the app’s sign-in experience.
No explicit cookie ageThe browser treats it as a session cookie, while store expiry remains separate.Set maxAge only when the desired browser lifetime is known.
Store record expires firstThe browser may send an ID that no longer resolves to a session.Configure the store TTL alongside cookie age.
Rolling cookierolling: true refreshes cookie expiry after responses.Check that the store renews its record as expected.
Absolute session limitRolling expiry can extend an active session’s idle window.Enforce a separate application rule when sign-in must end after a fixed duration.

Do not expose logout as a GET action because browsers or link previews can request a URL without an intentional form submission. Use POST for logout and protect state-changing requests against CSRF. sameSite can reduce cross-site cookie sending, but it is not a complete CSRF defense.

Conclusion: keep the session ID and session data distinct

This example isolates the login-session lifecycle. Password verification, CSRF protection, and persistent shared storage depend on the surrounding application’s design.

For the next step, check the official Express session middleware documentation for store methods and cookie options, then use a shared store when the app runs in more than one process.

FAQ

These answers cover the session behavior in the example.

Does express-session store session data in the browser cookie?

No. The browser cookie carries the signed session ID. Session data stays in the configured server-side store.

Why should the session ID change after login?

Regenerating the ID after successful authentication separates the signed-in session from an ID that may have existed before login.

Is MemoryStore suitable for production?

No. The express-session documentation says MemoryStore is intended for development and does not scale across multiple processes. Choose a production-ready shared store for deployment.

What does logout need to do?

Destroy the server-side session record, then clear the matching session cookie in the browser.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 337