Authentication: sessions and JWTs

Express · Practical · 16 min

Nearly every real app needs to know who is calling, and auth mistakes are among the most damaging bugs.

Authentication answers "who is this?" on every request. Two mainstream approaches:

Server-side sessions. After login, the server stores session data (user id) in a store such as Redis and sends the client a cookie containing only a random session id (express-session). Each request's cookie looks up the session.

  • Easy to revoke (delete the session), small cookies, works naturally for browser apps.
  • Needs a shared store when you run multiple instances. Never use the default in-memory store in production.

JWT bearer tokens. After login, the server signs a token (jsonwebtoken) containing claims like sub and exp; the client sends Authorization: Bearer <token>, and the server verifies the signature without a lookup.

  • Stateless and convenient for APIs consumed by mobile apps and other services.
  • Hard to revoke before expiry, so keep access tokens short-lived (minutes) and pair them with refresh tokens. Pin the algorithm on verify (algorithms: ["HS256"]).

For browser front-ends, cookies are usually the safer transport: set them `httpOnly` (invisible to JavaScript, so XSS can't steal them), `secure` (HTTPS only) and `sameSite: "lax"` (blocks most CSRF). Tokens stored in localStorage are readable by any injected script.

Always:

  • Hash passwords with a slow algorithm — bcrypt, argon2, or Node's built-in crypto.scrypt — never plain SHA-256.
  • Read secrets from environment variables.
  • Return the same error for "unknown user" and "wrong password", and rate-limit login.

Protect routes with a middleware that verifies credentials, sets req.user, and responds 401 otherwise; check permissions (403) separately. For "Sign in with Google" and similar, use an OpenID Connect library rather than rolling your own.

Example

import express from "express";
import jwt from "jsonwebtoken";
import { scrypt, randomBytes, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";

const scryptAsync = promisify(scrypt);
const SECRET = process.env.JWT_SECRET ?? "dev-only-secret";
const hash = async (pw, salt = randomBytes(16)) => ({ salt, key: await scryptAsync(pw, salt, 64) });
const users = new Map([["ada@example.com", { id: "u1", ...(await hash("correct horse")) }]]);

const app = express();
app.use(express.json());

app.post("/login", async (req, res) => {
  const user = users.get(req.body?.email);
  const key = user && (await scryptAsync(String(req.body.password), user.salt, 64));
  if (!user || !timingSafeEqual(key, user.key)) return res.status(401).json({ error: "invalid credentials" });
  res.json({ token: jwt.sign({ sub: user.id }, SECRET, { expiresIn: "15m" }) });
});

function requireAuth(req, res, next) {
  const token = req.get("authorization")?.replace(/^Bearer /, "");
  try { req.user = jwt.verify(token, SECRET, { algorithms: ["HS256"] }); next(); }
  catch { res.status(401).json({ error: "unauthenticated" }); }
}

app.get("/me", requireAuth, (req, res) => res.json({ userId: req.user.sub }));
app.listen(3000);

Remember

  • Sessions: server-side state + cookie id; easy revocation, needs a shared store
  • JWTs: signed, stateless, short-lived; pin the algorithm when verifying
  • Cookies for browsers: httpOnly, secure, sameSite
  • Hash passwords with scrypt/bcrypt/argon2; 401 for unauthenticated, 403 for forbidden

Watch out for

  • Storing JWTs in localStorage in a browser app, exposing them to XSS.
  • Using express-session's default MemoryStore in production.

Check yourself

  1. Why are JWT access tokens usually given short lifetimes?
  2. Which cookie flag stops client-side JavaScript from reading the session cookie?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: expressjs.com/en/resources/middleware/session