Issuing the Session on the Server
A complete login, session-read and logout implementation you can lift into a real API.
This lesson is the server half of cookie authentication, written out in full. The pieces are: a login route that verifies a password and issues a session, middleware that turns a cookie into req.user, and a logout route that destroys both sides.
What the login route must do
- Rate-limit by IP and by account, before touching the database.
- Look up the user and compare the password with a slow hash.
- Return the same generic error for unknown-user and wrong-password.
- Create a fresh session id — never reuse one supplied by the client.
- Set the cookie with the right attributes.
- Return the user profile so the frontend can render immediately, without a second round-trip.
What the middleware must do
Read the cookie, look up the session, reject if missing or expired, refresh the idle timeout, and attach the identity. Nothing else — resist the urge to also load a heavy user object on every request; load the id and fetch the rest only where it is needed.
Idle vs absolute timeout
Two different clocks, and you want both. The idle timeout (say 30 minutes) slides forward on each request. The absolute timeout (say 12 hours) does not — it caps how long a stolen session can be kept alive by an attacker who simply keeps using it.
Do not forget the store
An in-memory session map works on one process and breaks the moment you run two. Redis with a TTL is the default answer; a database table works too, provided you index the id column and actually clean up expired rows.
Example
app.post('/login', loginLimiter, async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
const hash = user?.passwordHash ?? DUMMY_HASH; // constant work either way
const ok = await argon2.verify(hash, password).catch(() => false);
if (!user || !ok) {
return res.status(401).json({ error: 'invalid_credentials' });
}
const sid = await createSession(user.id, req); // fresh id, always
res.cookie('__Host-sid', sid, COOKIE_OPTIONS);
res.json({ user: { id: user.id, email: user.email, name: user.name } });
});When to use it
- An API returns the user profile in the login response so the SPA can render the header without an extra /me request on every login.
- A session carries both a sliding 30-minute idle timeout and a hard 12-hour cap, so a stolen cookie cannot be kept alive indefinitely by an idle-refresh script.
- Login attempts are limited per account as well as per IP, which stops a distributed botnet from spraying one high-value account from thousands of addresses.
More examples
The complete server, end to end
Note that logout deletes the store record first. Clearing the cookie alone leaves a live session that anyone holding a copy of the id can keep using.
import express from 'express';
import cookieParser from 'cookie-parser';
import argon2 from 'argon2';
import rateLimit from 'express-rate-limit';
import { randomBytes, createHash } from 'crypto';
const app = express();
app.use(express.json());
app.use(cookieParser());
const IDLE_S = 30 * 60; // sliding
const ABSOLUTE_MS = 12 * 60 * 60e3; // hard cap
const COOKIE = '__Host-sid';
const COOKIE_OPTIONS = {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
maxAge: IDLE_S * 1000,
};
const key = (sid) => 'sess:' + createHash('sha256').update(sid).digest('hex');
async function createSession(userId, req) {
const sid = randomBytes(32).toString('base64url');
await redis.set(key(sid), JSON.stringify({
userId, createdAt: Date.now(), ip: req.ip,
}), 'EX', IDLE_S);
return sid;
}
// --- middleware: cookie → req.user ---
export async function session(req, res, next) {
const sid = req.cookies[COOKIE];
if (!sid) return res.status(401).json({ error: 'authentication_required' });
const raw = await redis.get(key(sid));
if (!raw) return res.status(401).json({ error: 'session_expired' });
const data = JSON.parse(raw);
if (Date.now() - data.createdAt > ABSOLUTE_MS) { // absolute timeout
await redis.del(key(sid));
res.clearCookie(COOKIE, { path: '/', secure: true, sameSite: 'lax' });
return res.status(401).json({ error: 'session_expired' });
}
await redis.expire(key(sid), IDLE_S); // slide the idle timeout
req.user = { id: data.userId };
req.sid = sid;
next();
}
app.get('/api/me', session, async (req, res) => {
res.json({ user: await db.users.findById(req.user.id) });
});
app.post('/logout', session, async (req, res) => {
await redis.del(key(req.sid)); // kill the server side
res.clearCookie(COOKIE, { path: '/', secure: true, sameSite: 'lax' });
res.status(204).end();
});Rate limiting per IP and per account
Counting failures per account is the half most teams skip, and it is the half that matters against credential stuffing from a large IP pool.
import rateLimit from 'express-rate-limit';
// Per IP: stops one machine hammering the endpoint
const ipLimiter = rateLimit({
windowMs: 15 * 60e3, limit: 20,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_attempts' },
});
// Per account: stops a botnet spraying one high-value login from many IPs
async function accountLimiter(req, res, next) {
const email = String(req.body?.email || '').toLowerCase();
if (!email) return next();
const k = `login-fail:${email}`;
const fails = Number(await redis.get(k)) || 0;
if (fails >= 10) {
return res.status(429).json({ error: 'too_many_attempts', retryAfter: 900 });
}
res.on('finish', () => {
if (res.statusCode === 401) redis.multi().incr(k).expire(k, 900).exec();
if (res.statusCode === 200) redis.del(k);
});
next();
}
export const loginLimiter = [ipLimiter, accountLimiter];
Discussion