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-incrypto.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.