Authentication Patterns

intermediate express auth passport jwt

Authentication in Express boils down to a recurring pattern: a route handler that verifies “who is this user?” before letting them touch protected stuff. We have two big choices for how to wire that up — Passport.js (batteries-included) or roll-our-own middleware.

In simple language: Passport is like buying an IKEA shelf — pick a strategy, snap it together. Custom middleware is woodworking from scratch. Both work, the tradeoff is flexibility vs control.

The auth flow at a glance

Login → Token → Protected Request
1. Client → POST /login { email, pwd }
2. Server: verify pwd, sign JWT, return tokens
3. Client stores access + refresh tokens
4. Client → GET /me Authorization: Bearer <access>
5. Middleware verifies → attaches req.user
When access expires: POST /refresh → new access

Option 1: Passport.js

Passport is a middleware layer that abstracts auth into “strategies”. One strategy for local username/password, one for Google OAuth, one for JWT, etc. We pick what we need.

npm install passport passport-local passport-jwt
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";

passport.use(new LocalStrategy(
  { usernameField: "email" },
  async (email, password, done) => {
    const user = await db.users.findByEmail(email);
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      return done(null, false, { message: "Invalid credentials" });
    }
    return done(null, user);
  }
));

passport.use(new JwtStrategy({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET,
}, async (payload, done) => {
  const user = await db.users.findById(payload.sub);
  return user ? done(null, user) : done(null, false);
}));

app.post("/login",
  passport.authenticate("local", { session: false }),
  (req, res) => {
    const token = jwt.sign({ sub: req.user.id }, process.env.JWT_SECRET, { expiresIn: "15m" });
    res.json({ token });
  }
);

app.get("/me", passport.authenticate("jwt", { session: false }), (req, res) => {
  res.json({ user: req.user });
});

The win: 500+ strategies for OAuth providers (Google, GitHub, Facebook). The cost: a learning curve, and the API feels dated.

Option 2: Custom middleware

For most APIs we control end-to-end, a 20-line middleware beats pulling in Passport. Honest take: I reach for this 80% of the time.

import jwt from "jsonwebtoken";

export function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing token" });
  }
  try {
    const payload = jwt.verify(header.slice(7), process.env.JWT_SECRET);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch {
    res.status(401).json({ error: "Invalid or expired token" });
  }
}

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

Want role-based access? Compose another middleware.

const requireRole = (role) => (req, res, next) =>
  req.user?.role === role ? next() : res.status(403).end();

app.delete("/users/:id", requireAuth, requireRole("admin"), deleteUser);

Refresh tokens

Short-lived access tokens (15 min) limit damage if one leaks. But forcing users to log in every 15 minutes is hell. Refresh tokens fix this — a long-lived (7-30 day) token that only does one thing: get new access tokens.

app.post("/login", async (req, res) => {
  const user = await verifyCredentials(req.body);
  const accessToken = jwt.sign({ sub: user.id }, ACCESS_SECRET, { expiresIn: "15m" });
  const refreshToken = jwt.sign({ sub: user.id }, REFRESH_SECRET, { expiresIn: "30d" });

  // store refresh token hash in DB so we can revoke it
  await db.refreshTokens.insert({ userId: user.id, tokenHash: sha256(refreshToken) });

  res.json({ accessToken, refreshToken });
});

app.post("/refresh", async (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, REFRESH_SECRET);
    const stored = await db.refreshTokens.findByHash(sha256(refreshToken));
    if (!stored) return res.status(401).end(); // revoked

    const accessToken = jwt.sign({ sub: payload.sub }, ACCESS_SECRET, { expiresIn: "15m" });
    res.json({ accessToken });
  } catch {
    res.status(401).end();
  }
});

Key idea: refresh tokens live in the DB so we can revoke them on logout. Access tokens are stateless and trusted as long as the signature checks out.

Which to pick?

  • Building an API for your own clients? Custom middleware.
  • Need Google/GitHub/SSO login? Passport, no contest.
  • Mixing both? Totally fine — Passport for OAuth, custom JWT middleware for your API routes.