Skip to content
Express.js projects 6 min read

Project: Authentication Service

Authentication is the gateway to almost every serious application, and getting it wrong is one of the fastest ways to leak user data. In this project you will build a standalone authentication service with Express that handles registration, login, refresh-token rotation, password resets via email, rate limiting, and secure cookie handling. The patterns here are framework-agnostic enough to drop into any Express API, whether it serves a single SPA or a fleet of microservices.

Architecture overview

The service issues two tokens on login. A short-lived access token (a signed JWT, ~15 minutes) is sent to the client and attached to API requests. A long-lived refresh token (~7 days) is stored in an httpOnly cookie and persisted server-side so it can be rotated and revoked. Access tokens stay stateless; refresh tokens stay stateful. This split gives you both performance and the ability to log a user out everywhere.

TokenLifetimeStorage (client)Storage (server)Purpose
Access~15 minIn memoryNone (stateless)Authorize API calls
Refresh~7 dayshttpOnly cookieHashed in DBMint new access tokens

Project setup

npm init -y
npm install express bcrypt jsonwebtoken cookie-parser express-rate-limit nodemailer zod
npm install -D nodemon

Keep all secrets in environment variables. Never commit them.

# .env
ACCESS_TOKEN_SECRET=replace-with-32+-random-bytes
REFRESH_TOKEN_SECRET=another-32+-random-bytes
NODE_ENV=production

Hashing passwords and creating tokens

Passwords are never stored in plaintext. bcrypt salts and hashes them with a tunable work factor. The token helpers centralize signing so every route uses consistent claims and expiries.

import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

const ACCESS_TTL = "15m";
const REFRESH_TTL = "7d";

export const hashPassword = (plain) => bcrypt.hash(plain, 12);
export const verifyPassword = (plain, hash) => bcrypt.compare(plain, hash);

export const signAccessToken = (user) =>
  jwt.sign({ sub: user.id, email: user.email }, process.env.ACCESS_TOKEN_SECRET, {
    expiresIn: ACCESS_TTL,
  });

export const signRefreshToken = (user, tokenId) =>
  jwt.sign({ sub: user.id, jti: tokenId }, process.env.REFRESH_TOKEN_SECRET, {
    expiresIn: REFRESH_TTL,
  });

Store a hash of the refresh token in your database, not the raw token. If your DB is compromised, the leaked rows cannot be replayed as valid sessions.

Registration and login

Validate input at the edge with zod, hash the password, and persist the user. On login, verify the password and issue both tokens. The refresh token goes into a secure cookie; the access token goes in the JSON body.

import { Router } from "express";
import crypto from "crypto";
import { z } from "zod";
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken } from "./auth.js";
import { db } from "./db.js";

const router = Router();
const creds = z.object({ email: z.string().email(), password: z.string().min(8) });

const cookieOpts = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict",
  path: "/auth/refresh",
  maxAge: 7 * 24 * 60 * 60 * 1000,
};

router.post("/register", async (req, res) => {
  const { email, password } = creds.parse(req.body);
  const existing = await db.users.findByEmail(email);
  if (existing) return res.status(409).json({ error: "Email already registered" });

  const user = await db.users.create({ email, passwordHash: await hashPassword(password) });
  res.status(201).json({ id: user.id, email: user.email });
});

router.post("/login", async (req, res) => {
  const { email, password } = creds.parse(req.body);
  const user = await db.users.findByEmail(email);
  if (!user || !(await verifyPassword(password, user.passwordHash))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  const tokenId = crypto.randomUUID();
  const refreshToken = signRefreshToken(user, tokenId);
  await db.refreshTokens.save({ id: tokenId, userId: user.id, hash: sha256(refreshToken) });

  res
    .cookie("rt", refreshToken, cookieOpts)
    .json({ accessToken: signAccessToken(user) });
});

const sha256 = (v) => crypto.createHash("sha256").update(v).digest("hex");

export default router;

Output: a successful login response body.

HTTP/1.1 200 OK
Set-Cookie: rt=eyJhbGciOi...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
Content-Type: application/json

{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }

Refresh-token rotation

Rotation defends against token theft. Every time a refresh token is used, it is invalidated and a brand-new one is issued. If an attacker replays an already-used token, the lookup fails, and you can treat it as a breach and revoke the whole session family.

import jwt from "jsonwebtoken";

router.post("/refresh", async (req, res) => {
  const token = req.cookies.rt;
  if (!token) return res.status(401).json({ error: "Missing refresh token" });

  let payload;
  try {
    payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
  } catch {
    return res.status(401).json({ error: "Invalid refresh token" });
  }

  const stored = await db.refreshTokens.findById(payload.jti);
  if (!stored || stored.hash !== sha256(token)) {
    await db.refreshTokens.revokeAllForUser(payload.sub); // possible replay attack
    return res.status(401).json({ error: "Token reuse detected" });
  }

  await db.refreshTokens.delete(payload.jti); // rotate: old token is now dead
  const user = await db.users.findById(payload.sub);
  const newId = crypto.randomUUID();
  const newRefresh = signRefreshToken(user, newId);
  await db.refreshTokens.save({ id: newId, userId: user.id, hash: sha256(newRefresh) });

  res.cookie("rt", newRefresh, cookieOpts).json({ accessToken: signAccessToken(user) });
});

The protect middleware

A small middleware verifies the access token and attaches the user to the request. Reuse it on any guarded route.

import jwt from "jsonwebtoken";

export const protect = (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: "Unauthorized" });

  try {
    req.user = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Token expired or invalid" });
  }
};

In Express 5, errors thrown in async handlers are forwarded to your error middleware automatically. In Express 4 you must wrap handlers or call next(err) yourself, so a try/catch like the above is still required.

Password reset by email

Reset flows must never reveal whether an email exists. Always respond with the same generic message. Store a hashed, single-use token with a short expiry and email the raw token as part of a link.

import nodemailer from "nodemailer";

const mailer = nodemailer.createTransport({ url: process.env.SMTP_URL });

router.post("/forgot-password", async (req, res) => {
  const { email } = z.object({ email: z.string().email() }).parse(req.body);
  const user = await db.users.findByEmail(email);

  if (user) {
    const raw = crypto.randomBytes(32).toString("hex");
    await db.resetTokens.save({
      userId: user.id,
      hash: sha256(raw),
      expiresAt: Date.now() + 15 * 60 * 1000,
    });
    await mailer.sendMail({
      to: email,
      subject: "Reset your password",
      text: `Reset link: https://app.example.com/reset?token=${raw}`,
    });
  }
  res.json({ message: "If that email exists, a reset link has been sent." });
});

router.post("/reset-password", async (req, res) => {
  const { token, password } = z
    .object({ token: z.string(), password: z.string().min(8) })
    .parse(req.body);

  const record = await db.resetTokens.findByHash(sha256(token));
  if (!record || record.expiresAt < Date.now()) {
    return res.status(400).json({ error: "Invalid or expired token" });
  }

  await db.users.updatePassword(record.userId, await hashPassword(password));
  await db.resetTokens.delete(record.id);
  await db.refreshTokens.revokeAllForUser(record.userId); // force re-login everywhere
  res.json({ message: "Password updated" });
});

Rate limiting

Brute-force protection belongs on the auth endpoints. express-rate-limit caps attempts per IP. Apply a tight limit to login and reset, and wire up cookies globally.

import express from "express";
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit";
import authRoutes from "./routes.js";

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

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 10,
  standardHeaders: "draft-7",
  message: { error: "Too many attempts, try again later." },
});

app.use("/auth/login", authLimiter);
app.use("/auth/forgot-password", authLimiter);
app.use("/auth", authRoutes);

app.listen(3000, () => console.log("Auth service on :3000"));

Best practices

  • Use a bcrypt work factor of at least 12, and benchmark it on your hardware so a hash takes ~250 ms.
  • Keep access tokens short-lived and stateless; keep refresh tokens stateful so you can revoke them.
  • Always set httpOnly, secure, and sameSite=strict on auth cookies to blunt XSS and CSRF.
  • Rotate refresh tokens on every use and treat any reuse of a rotated token as a full-session compromise.
  • Return identical responses from forgot-password regardless of whether the email exists, to prevent account enumeration.
  • Rate-limit login and password-reset endpoints, and consider a per-account lockout in addition to per-IP limits.
  • Revoke all sessions after a password change so stolen tokens stop working immediately.
Last updated June 14, 2026
Was this helpful?