Skip to content
Express.js ex auth 5 min read

Session-Based Authentication

Session-based authentication keeps the user’s identity on the server and hands the browser only an opaque session ID inside a cookie. On every request the browser sends that cookie back, Express looks up the matching session record, and decides whether the user is logged in. Because the real state lives server-side, you can revoke a session instantly by deleting one record — a property stateless tokens cannot match. This page builds login and logout with express-session, moves the session store into Redis so it survives restarts and scales horizontally, protects routes by inspecting req.session, and handles expiry cleanly.

How the session flow works

When a user logs in successfully, you write their identity (typically a user ID) into req.session. express-session serializes that object into a store, generates a signed session ID, and sets it as a cookie. On subsequent requests the middleware reads the cookie, verifies its signature against your secret, loads the session from the store, and exposes it as req.session. Logging out destroys the store record and clears the cookie.

ConceptWhere it livesPurpose
Session IDSigned cookie in the browserOpaque pointer to the server record
Session dataServer store (memory / Redis / DB)Holds user ID, roles, flash messages
secretServer environmentSigns the cookie so it cannot be forged
maxAge / TTLCookie and storeBounds how long a login stays valid

Configuring express-session with Redis

The default in-memory store leaks memory and breaks the instant you run more than one process, so it is for prototyping only. connect-redis keeps sessions in Redis, letting every instance behind a load balancer read the same session and surviving a deploy.

npm install express express-session connect-redis ioredis bcrypt
// app.js
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const Redis = require('ioredis');

const app = express();
const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379');

app.use(express.json());
app.use(
  session({
    store: new RedisStore({ client: redis, prefix: 'sess:' }),
    secret: process.env.SESSION_SECRET, // signs the session cookie
    resave: false,            // skip rewriting unchanged sessions
    saveUninitialized: false, // don't persist empty/anonymous sessions
    rolling: true,            // refresh maxAge on every response
    cookie: {
      httpOnly: true,         // block JS access — mitigates XSS theft
      secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
      sameSite: 'lax',        // CSRF mitigation for top-level navigation
      maxAge: 1000 * 60 * 60 * 24, // 1 day, also used as the Redis TTL
    },
  })
);

module.exports = { app, redis };

Behind a proxy or load balancer that terminates TLS, add app.set('trust proxy', 1) so secure cookies are issued correctly — otherwise Express thinks the connection is plain HTTP and refuses to send them.

Building login and logout

A real login compares the submitted password against a stored hash with bcrypt, then stores only the user ID in the session. Never put the password or the full user object in the session — keep it small and look up fresh data when you need it.

// routes/auth.js
const express = require('express');
const bcrypt = require('bcrypt');
const router = express.Router();
const db = require('../db'); // your data access layer

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await db.findUserByEmail(email);

    // Same generic error whether the email or password is wrong
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // Prevent session fixation: issue a fresh session ID on login
    req.session.regenerate((err) => {
      if (err) return next(err);
      req.session.userId = user.id;
      req.session.role = user.role;
      res.json({ id: user.id, email: user.email });
    });
  } catch (err) {
    next(err);
  }
});

router.post('/logout', (req, res, next) => {
  req.session.destroy((err) => {
    if (err) return next(err);
    res.clearCookie('connect.sid'); // default session cookie name
    res.json({ message: 'Logged out' });
  });
});

module.exports = router;

Logging in returns a Set-Cookie header the browser stores and replays automatically.

curl -i -X POST http://localhost:3000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"correct horse"}'

Output:

HTTP/1.1 200 OK
Set-Cookie: connect.sid=s%3Aa9f3...; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400
Content-Type: application/json; charset=utf-8

{"id":42,"email":"[email protected]"}

Protecting routes

Guard private routes with a small middleware that checks for the identity you stored at login. Because req.session is already populated by express-session, the check is synchronous and cheap.

// middleware/requireAuth.js
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

module.exports = requireAuth;
const requireAuth = require('./middleware/requireAuth');

app.get('/me', requireAuth, async (req, res) => {
  const user = await db.findUserById(req.session.userId);
  res.json({ id: user.id, email: user.email, role: req.session.role });
});

Apply it to a whole router with router.use(requireAuth) to protect every route below the line in one stroke.

Handling session expiry

Expiry is enforced in two places that should agree. The cookie’s maxAge tells the browser when to stop sending the ID, and the Redis TTL (derived from the same maxAge) removes the server record so it cannot be replayed. With rolling: true, each response resets both, giving you a sliding inactivity window — a user who keeps clicking stays logged in, while an idle session expires on its own. When a session expires, req.session.userId is simply undefined, so requireAuth returns a 401 with no extra work. To force a logout everywhere (a “log out all devices” feature), delete the user’s session keys directly: await redis.del('sess:' + sessionId).

Always set httpOnly so client JavaScript cannot read the cookie, and pair sameSite with a CSRF token for state-changing requests — cookies are sent automatically, which is exactly what makes CSRF possible.

Best Practices

  • Store only an opaque userId (and maybe a role) in the session; fetch the full user record on demand.
  • Call req.session.regenerate() on login to defeat session fixation attacks.
  • Use a Redis or database store in production — never the default in-memory store across multiple instances.
  • Set httpOnly, secure, and sameSite on the cookie, and keep SESSION_SECRET out of source control.
  • Prefer rolling: true for a sliding inactivity timeout, and set a sensible maxAge so abandoned sessions expire.
  • In Express 5, async handlers that reject are forwarded to error middleware automatically; in Express 4 you must call next(err) yourself as shown above.
Last updated June 14, 2026
Was this helpful?