Most real applications need to know who is making a request. This lesson covers the two dominant approaches in the Express world: server-side sessions with cookies, and stateless JSON Web Tokens (JWT). You will learn how each works, how to implement both, and how to choose between them. One rule applies everywhere: passwords are always hashed, never stored in plain text.
Whatever strategy you pick, registration looks the same. Hash the password with bcrypt and store only the hash:
npm install bcryptconst bcrypt = require('bcrypt');
app.post('/api/register', async (req, res) => {
const { email, password } = req.body;
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: 'Email and 8+ char password required' });
}
const passwordHash = await bcrypt.hash(password, 12);
const user = await User.create({ email, passwordHash });
res.status(201).json({ id: user._id, email: user.email });
});bcrypt is deliberately slow, which is exactly what you want against brute-force attacks. Verify at login with bcrypt.compare(password, user.passwordHash).
With sessions, the server remembers logged-in users. On login you store the user ID in a session; the browser gets a signed cookie holding just the session ID:
npm install express-sessionconst session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: true, maxAge: 86400000 },
}));
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
const ok = user && await bcrypt.compare(req.body.password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
req.session.userId = user._id;
res.json({ message: 'Logged in' });
});Protect routes with a tiny guard that checks req.session.userId and returns 401 otherwise. Logout is req.session.destroy(). In production, store sessions in Redis or MongoDB instead of the default memory store, which leaks memory and empties on restart.
JWTs flip the model: the server remembers nothing. On login it signs a token containing the user ID; the client sends it back in the Authorization header of every request:
npm install jsonwebtokenconst jwt = require('jsonwebtoken');
app.post('/api/login', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
const ok = user && await bcrypt.compare(req.body.password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
const token = jwt.sign({ sub: user._id }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
});
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Token required' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
app.get('/api/profile', requireAuth, async (req, res) => {
res.json({ userId: req.user.sub });
});Anyone can decode a JWT payload, so never put secrets in it; the signature only guarantees it was not modified. Keep lifetimes short and pair access tokens with refresh tokens for longer sessions.
Sessions suit server-rendered sites: revocation is instant (delete the session) and cookies with httpOnly resist token theft via XSS. JWTs suit APIs consumed by mobile apps, SPAs, and microservices, because any server holding the secret can verify requests without shared session storage. Many production systems combine both ideas: short-lived JWTs delivered in httpOnly cookies.
Final lesson: Deploying an Express App and Best Practices, where your project goes live.