Skip to content
Node.js projects 6 min read

Project: JWT Authentication System

Almost every backend eventually needs to answer two questions: who is this request from? and are they allowed to do this? In this project you build a complete authentication system from scratch — user registration with hashed passwords, a login endpoint that issues JSON Web Tokens, short-lived access tokens paired with long-lived refresh tokens, middleware that guards protected routes, and a password-reset flow. By the end you will have a small but production-shaped Express API that you can lift into any real application.

What you’ll build

A REST API with the following endpoints. Access tokens are short-lived (15 minutes) and sent on every request; refresh tokens are long-lived (7 days), stored server-side so they can be revoked, and exchanged for new access tokens when the old one expires.

MethodRoutePurposeAuth required
POST/auth/registerCreate an accountNo
POST/auth/loginVerify credentials, issue tokensNo
POST/auth/refreshSwap a refresh token for a new access tokenNo (refresh token)
POST/auth/logoutRevoke a refresh tokenNo (refresh token)
GET/meReturn the current userYes (access token)
POST/auth/forgot-passwordEmail a reset tokenNo
POST/auth/reset-passwordSet a new passwordNo (reset token)

Project setup

Initialise the project and install the dependencies. We use ES modules, so set "type": "module" in package.json.

npm init -y
npm install express bcrypt jsonwebtoken
npm pkg set type=module

Set secrets through environment variables — never hard-code them. Node 20.6+ can load a .env file natively, so no extra package is needed.

node --env-file=.env server.js
# .env
JWT_ACCESS_SECRET=replace-with-32-random-bytes
JWT_REFRESH_SECRET=replace-with-a-different-32-random-bytes

Step 1 — Register with hashed passwords

Never store raw passwords. bcrypt salts and hashes each password with a tunable cost factor, so even if your database leaks, the originals stay protected. We hash on registration and discard the plaintext immediately.

import express from "express";
import bcrypt from "bcrypt";

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

const users = new Map(); // email -> { id, email, passwordHash }
let nextId = 1;

app.post("/auth/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" });
  }
  if (users.has(email)) {
    return res.status(409).json({ error: "Email already registered" });
  }
  const passwordHash = await bcrypt.hash(password, 12); // cost factor 12
  const user = { id: nextId++, email, passwordHash };
  users.set(email, user);
  res.status(201).json({ id: user.id, email: user.email });
});

Output:

$ curl -X POST localhost:3000/auth/register \
    -H 'Content-Type: application/json' \
    -d '{"email":"[email protected]","password":"supersecret"}'
{"id":1,"email":"[email protected]"}

Step 2 — Login and issue tokens

On login, compare the submitted password against the stored hash with bcrypt.compare. If it matches, mint an access token and a refresh token. We persist the refresh token (here, in a Set) so it can later be revoked.

import jwt from "jsonwebtoken";

const refreshStore = new Set(); // jti values still valid

function issueTokens(user) {
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: "15m" }
  );
  const jti = crypto.randomUUID();
  const refreshToken = jwt.sign(
    { sub: user.id, jti },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: "7d" }
  );
  refreshStore.add(jti);
  return { accessToken, refreshToken };
}

app.post("/auth/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.get(email);
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: "Invalid credentials" });
  }
  res.json(issueTokens(user));
});

Use a generic “Invalid credentials” message for both unknown emails and wrong passwords. Telling an attacker which part failed hands them a way to enumerate valid accounts.

Step 3 — Protect routes with middleware

A guard middleware reads the Authorization: Bearer <token> header, verifies the signature and expiry, and attaches the decoded user to the request. Any route that needs authentication simply lists it.

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: "Missing token" });
  try {
    req.user = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

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

Step 4 — Refresh and logout

When the access token expires, the client posts its refresh token here. We verify the signature, confirm its jti is still in the store (not revoked), and issue a fresh access token. Logout simply removes the jti, invalidating the refresh token.

app.post("/auth/refresh", (req, res) => {
  const { refreshToken } = req.body;
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    if (!refreshStore.has(payload.jti)) {
      return res.status(401).json({ error: "Refresh token revoked" });
    }
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: "15m" }
    );
    res.json({ accessToken });
  } catch {
    res.status(401).json({ error: "Invalid refresh token" });
  }
});

app.post("/auth/logout", (req, res) => {
  try {
    const { jti } = jwt.verify(req.body.refreshToken, process.env.JWT_REFRESH_SECRET);
    refreshStore.delete(jti);
  } catch { /* already invalid — nothing to revoke */ }
  res.status(204).end();
});

Step 5 — Password reset flow

Reset works in two halves. forgot-password generates a single-use, short-lived token tied to the user and (in a real app) emails a link containing it. reset-password verifies that token and re-hashes the new password. We use a dedicated short expiry so leaked links go stale quickly.

app.post("/auth/forgot-password", (req, res) => {
  const user = users.get(req.body.email);
  // Always return 200 so attackers can't probe which emails exist.
  if (user) {
    const resetToken = jwt.sign(
      { sub: user.id },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: "15m" }
    );
    console.log(`Reset link: https://app.example.com/reset?token=${resetToken}`);
  }
  res.json({ message: "If that account exists, a reset link was sent" });
});

app.post("/auth/reset-password", async (req, res) => {
  const { token, password } = req.body;
  try {
    const { sub } = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
    const user = [...users.values()].find((u) => u.id === sub);
    if (!user) return res.status(400).json({ error: "Unknown user" });
    user.passwordHash = await bcrypt.hash(password, 12);
    res.json({ message: "Password updated" });
  } catch {
    res.status(400).json({ error: "Invalid or expired reset token" });
  }
});

app.listen(3000, () => console.log("Auth API on http://localhost:3000"));

Stretch goals

  • Swap the in-memory Map/Set for a real database (PostgreSQL or MongoDB) so data and tokens survive restarts.
  • Add email verification before activating new accounts.
  • Add rate limiting on /auth/login and /auth/forgot-password to slow brute-force attacks.
  • Send refresh tokens as httpOnly, Secure, SameSite cookies instead of JSON to mitigate XSS theft.
  • Add role-based authorization (e.g. admin vs user) on top of requireAuth.

Best Practices

  • Always hash passwords with bcrypt (or argon2) and a cost factor of 12+; never store or log plaintext.
  • Keep access tokens short-lived and refresh tokens long-lived but revocable via a server-side store.
  • Load every secret from the environment and use distinct secrets for access and refresh tokens.
  • Return identical responses for “user not found” and “wrong password” to prevent account enumeration.
  • Validate and sanitise all input before it reaches your business logic.
  • Always serve auth endpoints over HTTPS so tokens and credentials never cross the wire in clear text.
Last updated June 14, 2026
Was this helpful?