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.
| Concept | Where it lives | Purpose |
|---|---|---|
| Session ID | Signed cookie in the browser | Opaque pointer to the server record |
| Session data | Server store (memory / Redis / DB) | Holds user ID, roles, flash messages |
secret | Server environment | Signs the cookie so it cannot be forged |
maxAge / TTL | Cookie and store | Bounds 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)sosecurecookies 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
httpOnlyso client JavaScript cannot read the cookie, and pairsameSitewith 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, andsameSiteon the cookie, and keepSESSION_SECRETout of source control. - Prefer
rolling: truefor a sliding inactivity timeout, and set a sensiblemaxAgeso 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.