Skip to main content

๐Ÿ” Weekend Project: Build a Secure Authentication System

This is the weekend you build the feature every real app needs and almost every beginner gets subtly wrong: authentication. You'll design a complete auth system on Node and Express โ€” register users with bcrypt-hashed passwords, log them in with a short-lived JWT access token, keep them logged in with a rotating refresh token stored in an httpOnly cookie, protect routes with auth middleware, gate admin actions with role-based authorization, and harden the whole thing with rate limiting, helmet, and secure cookie flags. This is not a toy login form โ€” it's the token pattern production apps actually ship.

Week 9 · Weekend Project · Authentication & Security Capstone

๐ŸŽฏ Learning Objectives

By completing this project, you will be able to:

  • Store passwords safely by hashing with bcrypt (cost factor ~12) and never persisting or returning plaintext or the hash
  • Issue a short-lived JWT access token and a long-lived refresh token, and explain why the split makes revocation and XSS mitigation possible
  • Protect routes with auth middleware that verifies the access token and attaches the user to req
  • Implement refresh-token rotation, logout, and server-side revocation so a stolen or logged-out token stops working
  • Add role-based authorization so only an admin can reach admin routes โ€” separate from authentication
  • Harden auth endpoints with rate limiting, helmet security headers, and httpOnly + secure + sameSite cookie flags โ€” and write generic errors that avoid user enumeration

Estimated Time: 6โ€“9 hours across the weekend

Project: A runnable Express auth API โ€” /auth/register, /auth/login, /auth/refresh, /auth/logout, plus protected /me and an admin-only route โ€” using bcrypt, JWT access + rotating refresh tokens, RBAC, rate limiting, and helmet.

In This Project

The Goal

Build an HTTP service that proves who a user is and then keeps proving it on every request โ€” without asking for the password again and again. A visitor POSTs their email and password to /auth/register; you hash the password and store the user. They POST to /auth/login; you check the password and hand back two tokens. From then on, every protected request carries a short-lived access token in an Authorization header, and a long-lived refresh token rides silently in an httpOnly cookie so the browser sends it automatically but JavaScript can never read it.

Here's the whole handshake as a sequence. Read it top to bottom once โ€” every stage below builds one arrow of it.

sequenceDiagram participant C as Client participant A as Auth API participant DB as Database C->>A: POST /auth/register (email, password) A->>A: bcrypt.hash(password, 12) A->>DB: save user (hash only) A-->>C: 201 Created (no tokens yet) C->>A: POST /auth/login (email, password) A->>DB: find user by email A->>A: bcrypt.compare(password, hash) A-->>C: access token (JSON) + refresh token (httpOnly cookie) C->>A: GET /me (Authorization: Bearer access) A->>A: jwt.verify(access) A-->>C: 200 OK (profile) Note over C,A: access token expires after 15 min C->>A: POST /auth/refresh (cookie sent automatically) A->>DB: is refresh token valid and not revoked? A->>A: rotate: revoke old, issue new pair A-->>C: fresh access token + new refresh cookie

The single most important idea is the two-token split. A JWT is self-verifying โ€” the server checks its signature without a database lookup, which is fast, but also means the server can't easily cancel one before it expires. So we keep the access token short-lived (15 minutes): even if it leaks, it dies quickly. The refresh token lives much longer but is stored server-side and can be revoked instantly, and it's kept in an httpOnly cookie so a cross-site scripting attack can't steal it. Short access + revocable refresh gives you both speed and control.

๐Ÿ“– Authentication vs authorization

Two words that sound alike and are constantly confused. Authentication answers "who are you?" โ€” it's the login step and the token check. Authorization answers "are you allowed to do this?" โ€” it's the role check that lets an admin delete a user but stops a regular member. A failed authentication is 401 Unauthorized ("I don't know who you are"); a failed authorization is 403 Forbidden ("I know who you are, and no"). You'll build both, and keep them in separate middleware.

Prerequisites

This is the Week 9 capstone, so it rests on the whole "Authentication & Security" week plus the Node/Express foundation from Weeks 7โ€“8. Before you start, make sure you're comfortable with:

  • Express fundamentals โ€” express.json(), express.Router(), route params, and mounting routers under a prefix
  • Middleware โ€” the (req, res, next) signature, next() vs next(err), and a centralized 4-argument error handler
  • async/await & custom error classes โ€” class MyError extends Error carrying an err.statusCode
  • A database layer โ€” this guide shows Mongoose/MongoDB, but the pattern maps 1:1 onto Prisma, Knex, or a raw driver; only the model file changes
  • Hashing vs encryption โ€” hashing is one-way (you can't get the password back); that's exactly what you want for passwords
  • HTTP status codes โ€” especially 401, 403, 409, and 429

You'll need Node.js 18+, a running MongoDB (local or a free Atlas cluster), a terminal, and curl. If you'd rather use Postgres or SQLite, keep every controller identical and rewrite only models/User.js โ€” that's the payoff of isolating storage behind a model.

Required Features Checklist

These are the non-negotiables. Each one is a security decision, not just a feature โ€” tick them off as you go and don't skip the "why."

โœ… Must-have features

  • โ˜ Register with input validation and bcrypt hashing (cost ~12); the plaintext password is never stored or logged
  • โ˜ Login that verifies the password and issues a JWT access token + a refresh token in an httpOnly cookie
  • โ˜ Auth middleware that verifies the access token and rejects missing/expired/invalid tokens with 401
  • โ˜ Refresh endpoint with rotation โ€” each use revokes the old refresh token and issues a new one
  • โ˜ Logout that revokes the refresh token server-side and clears the cookie
  • โ˜ Role-based authorization โ€” an admin-only route protected by a separate requireRole middleware returning 403
  • โ˜ Rate limiting on /auth/login and /auth/register to blunt brute-force and credential-stuffing
  • โ˜ helmet security headers and secure cookie flags (httpOnly, secure, sameSite)
  • โ˜ Generic auth errors โ€” same "Invalid credentials" whether the email is unknown or the password is wrong (no user enumeration)
  • โ˜ Secrets in environment variables โ€” JWT signing keys and DB URL never hard-coded or committed

Project Structure

The layout mirrors the by-role structure from your API projects, with two auth-specific folders: utils/ for token helpers and a models/ that owns password hashing and refresh-token bookkeeping. Everything security-sensitive lives in a few small, auditable files.

secure-auth/
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ .env                     <-- secrets (gitignored!)
โ”œโ”€โ”€ .gitignore               <-- ignores .env, node_modules
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ server.js            <-- entry point: connects DB, app.listen()
    โ”œโ”€โ”€ app.js               <-- builds the Express app (helmet, json, cookies, routes)
    โ”œโ”€โ”€ config/
    โ”‚   โ”œโ”€โ”€ env.js           <-- reads & validates process.env
    โ”‚   โ””โ”€โ”€ db.js            <-- database connection helper
    โ”œโ”€โ”€ models/
    โ”‚   โ”œโ”€โ”€ User.js          <-- schema + bcrypt hashing + comparePassword()
    โ”‚   โ””โ”€โ”€ RefreshToken.js  <-- stored refresh tokens (for rotation/revocation)
    โ”œโ”€โ”€ routes/
    โ”‚   โ””โ”€โ”€ authRoutes.js    <-- express.Router(): URL โ†’ controller
    โ”œโ”€โ”€ controllers/
    โ”‚   โ””โ”€โ”€ authController.js<-- register, login, refresh, logout, me
    โ”œโ”€โ”€ middleware/
    โ”‚   โ”œโ”€โ”€ auth.js          <-- requireAuth + requireRole
    โ”‚   โ”œโ”€โ”€ validate.js      <-- express-validator rule chains + runner
    โ”‚   โ”œโ”€โ”€ rateLimit.js     <-- auth-endpoint limiters
    โ”‚   โ””โ”€โ”€ errorHandler.js  <-- one 4-arg error middleware + 404
    โ”œโ”€โ”€ utils/
    โ”‚   โ””โ”€โ”€ token.js         <-- sign/verify access & refresh tokens
    โ””โ”€โ”€ errors/
        โ””โ”€โ”€ AppError.js      <-- AppError + Unauthorized/Forbidden/Conflict

๐Ÿ’ก Why isolate tokens and hashing?

Security bugs cluster around two operations: how you store passwords and how you mint tokens. By putting hashing in models/User.js and all token logic in utils/token.js, you get two tiny files you can read end-to-end, review carefully, and unit-test โ€” instead of the same crypto scattered across ten controllers where one copy inevitably drifts out of sync.

Stage 1 โ€” Scaffold, Config & Helmet

Create the project and install the dependencies. Every package here earns its place: helmet sets protective headers, cookie-parser reads the refresh cookie, express-rate-limit throttles brute-force, and express-validator guards the request body.

mkdir secure-auth && cd secure-auth
npm init -y

# Runtime deps
npm install express mongoose bcrypt jsonwebtoken \
  cookie-parser helmet express-rate-limit express-validator dotenv

# Dev dep: auto-restart on save
npm install --save-dev nodemon

Secrets live in .env, which you must gitignore. Generate real random secrets โ€” never ship the placeholders. A quick way: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))", run once per secret.

# .env  โ€” NEVER commit this file
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/secure-auth

# Two SEPARATE secrets so an access key leak can't forge refresh tokens
JWT_ACCESS_SECRET=replace_with_64_random_hex_chars
JWT_REFRESH_SECRET=replace_with_a_DIFFERENT_64_random_hex_chars
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL=7d

Read and validate those variables in one place, so the app crashes loudly at boot if a secret is missing rather than mysteriously failing later.

// src/config/env.js
require('dotenv').config();

const required = ['MONGODB_URI', 'JWT_ACCESS_SECRET', 'JWT_REFRESH_SECRET'];
for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required env var: ${key}`);   // fail fast at startup
  }
}

module.exports = {
  nodeEnv: process.env.NODE_ENV || 'development',
  port: process.env.PORT || 3000,
  mongoUri: process.env.MONGODB_URI,
  accessSecret: process.env.JWT_ACCESS_SECRET,
  refreshSecret: process.env.JWT_REFRESH_SECRET,
  accessTtl: process.env.ACCESS_TOKEN_TTL || '15m',
  refreshTtl: process.env.REFRESH_TOKEN_TTL || '7d',
  isProd: (process.env.NODE_ENV || 'development') === 'production',
};

Now build the app. helmet() goes on first so every response โ€” including errors โ€” carries its security headers. cookieParser() must come before any route that reads req.cookies.

// src/app.js โ€” builds the app, does NOT listen
const express = require('express');
const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const authRoutes = require('./routes/authRoutes');
const { notFoundHandler, errorHandler } = require('./middleware/errorHandler');

function createApp() {
  const app = express();

  app.use(helmet());                 // secure HTTP headers, first in the chain
  app.use(express.json());           // parse JSON bodies โ†’ req.body
  app.use(cookieParser());           // parse cookies โ†’ req.cookies

  // If you deploy behind a proxy (Heroku, Nginx), trust it so `secure`
  // cookies work and rate-limit sees the real client IP.
  app.set('trust proxy', 1);

  app.use('/auth', authRoutes);

  app.get('/health', (req, res) => res.json({ status: 'ok' }));

  app.use(notFoundHandler);          // after all routes
  app.use(errorHandler);             // LAST, and takes 4 args
  return app;
}

module.exports = createApp;
// src/server.js โ€” the only file that connects DB + opens a port
const createApp = require('./app');
const connectDb = require('./config/db');
const env = require('./config/env');

(async () => {
  await connectDb(env.mongoUri);
  createApp().listen(env.port, () => {
    console.log(`Auth API on http://localhost:${env.port}`);
  });
})();

โš ๏ธ The .env file is your crown jewels

A committed .env is one of the most common ways real projects get compromised โ€” attackers scan public GitHub for leaked secrets within minutes of a push. Add .env to .gitignore before your first commit, commit a .env.example with blank values instead, and if a secret ever does leak, rotate it (generate a new one) โ€” deleting the commit is not enough.

Stage 2 โ€” The User Model & bcrypt

A password must never be stored as text. Instead you store a bcrypt hash โ€” a one-way transformation that's deliberately slow to compute, so an attacker who steals your database still can't feasibly reverse the hashes. bcrypt also folds a random salt into every hash, so two users with the same password get different hashes and precomputed "rainbow table" attacks are useless.

Do the hashing in a pre('save') hook so it happens automatically and consistently โ€” no controller can forget it. The cost factor (here 12) sets how slow the hash is; higher is safer but slower. Twelve is the current sweet spot for interactive logins.

// src/models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const BCRYPT_COST = 12;   // ~250ms per hash โ€” slow for attackers, fine for one login

const userSchema = new mongoose.Schema(
  {
    email: {
      type: String,
      required: true,
      unique: true,       // DB-level guard against duplicate accounts
      lowercase: true,    // normalize so "A@x.com" and "a@x.com" are one user
      trim: true,
    },
    // "select: false" means queries never return the hash unless you ask.
    passwordHash: { type: String, required: true, select: false },
    role: { type: String, enum: ['user', 'admin'], default: 'user' },
  },
  { timestamps: true }
);

// Convenience setter: assign user.password = 'plain' and the hook hashes it.
userSchema.virtual('password').set(function (plain) {
  this._plainPassword = plain;
});

// Hash right before saving, only when a new password was provided.
userSchema.pre('save', async function () {
  if (!this._plainPassword) return;
  this.passwordHash = await bcrypt.hash(this._plainPassword, BCRYPT_COST);
  this._plainPassword = undefined;   // don't keep plaintext in memory
});

// Instance method: compare a candidate against the stored hash.
userSchema.methods.verifyPassword = function (candidate) {
  return bcrypt.compare(candidate, this.passwordHash);   // returns a Promise<boolean>
};

// Belt-and-suspenders: strip sensitive fields from any JSON we send.
userSchema.set('toJSON', {
  transform(doc, ret) {
    delete ret.passwordHash;
    delete ret.__v;
    return ret;
  },
});

module.exports = mongoose.model('User', userSchema);

โš ๏ธ Three rules you must never break

  • Never store plaintext. Only the bcrypt hash is persisted; the plaintext exists only for the milliseconds it takes to hash.
  • Never return the hash. select: false plus the toJSON transform keep it out of every response โ€” a leaked hash is still crackable offline.
  • Never log the password. Keep it out of console.log, error messages, and analytics. When you fetch a user for login you must opt the hash back in explicitly: User.findOne({ email }).select('+passwordHash').

๐Ÿ“– Why bcrypt and not SHA-256?

General-purpose hashes like SHA-256 are built to be fast โ€” great for checksums, terrible for passwords, because "fast" means an attacker can try billions of guesses per second. bcrypt (and its modern cousins scrypt and Argon2) are deliberately slow and memory-hard, and they include a tunable cost factor you can raise as hardware gets faster. That slowness is the whole point: it turns a database breach from a catastrophe into a merely bad day.

Stage 3 โ€” Token Utilities

A JWT (JSON Web Token) is a signed string with three dot-separated parts: a header, a JSON payload of claims (like the user id), and a signature. Because it's signed with your secret, the server can verify it hasn't been tampered with โ€” without a database lookup. That statelessness is the superpower and the catch: fast to check, but you can't un-issue one, which is exactly why the access token is short-lived.

Put all token logic in one file. Access and refresh tokens are signed with different secrets so leaking one never lets an attacker forge the other.

// src/utils/token.js
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const env = require('../config/env');

// Short-lived access token: identifies the user on protected routes.
function signAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role },        // "sub" = subject (the user id)
    env.accessSecret,
    { expiresIn: env.accessTtl }              // e.g. 15m
  );
}

// Verify an access token; throws if expired or tampered.
function verifyAccessToken(token) {
  return jwt.verify(token, env.accessSecret);  // returns the decoded payload
}

// The refresh token embeds a random, unguessable id (jti) we also store in the DB.
// Storing it is what makes rotation and revocation possible.
function signRefreshToken(user, jti) {
  return jwt.sign(
    { sub: user.id, jti },
    env.refreshSecret,
    { expiresIn: env.refreshTtl }             // e.g. 7d
  );
}

function verifyRefreshToken(token) {
  return jwt.verify(token, env.refreshSecret);
}

// A fresh, cryptographically-random id for each refresh token.
const newTokenId = () => crypto.randomUUID();

module.exports = {
  signAccessToken, verifyAccessToken,
  signRefreshToken, verifyRefreshToken, newTokenId,
};

The refresh token's jti (JWT ID) is the hook for revocation. We record each issued refresh token in its own collection; rotating or logging out flips it to revoked. Give it a TTL index so expired rows clean themselves up.

// src/models/RefreshToken.js
const mongoose = require('mongoose');

const refreshTokenSchema = new mongoose.Schema(
  {
    jti: { type: String, required: true, unique: true },     // matches the token's jti claim
    user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
    revoked: { type: Boolean, default: false },
    expiresAt: { type: Date, required: true },
  },
  { timestamps: true }
);

// TTL index: MongoDB deletes each doc once expiresAt passes.
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });

module.exports = mongoose.model('RefreshToken', refreshTokenSchema);

๐Ÿ’ก What should (and shouldn't) go in a JWT payload

A JWT is signed, not encrypted โ€” anyone can base64-decode the payload and read it. So put only non-secret identifiers there: the user id and role, yes; a password, credit-card number, or anything private, never. Keep it small, too โ€” the token rides on every request, so a bloated payload is a bandwidth tax on your whole API.

Stage 4 โ€” Register & Login

Now the two entry points. First, validation rules โ€” because the very first defense is refusing malformed input before it touches your logic.

// src/middleware/validate.js
const { body, validationResult } = require('express-validator');
const { ValidationError } = require('../errors/AppError');

const registerRules = [
  body('email').isEmail().withMessage('a valid email is required').normalizeEmail(),
  body('password')
    .isLength({ min: 8 }).withMessage('password must be at least 8 characters')
    .matches(/[a-z]/).withMessage('password needs a lowercase letter')
    .matches(/[A-Z]/).withMessage('password needs an uppercase letter')
    .matches(/[0-9]/).withMessage('password needs a number'),
];

const loginRules = [
  body('email').isEmail().normalizeEmail(),
  body('password').notEmpty(),
];

// Shared runner: turn any validation errors into one ValidationError (422).
function runValidation(req, res, next) {
  const result = validationResult(req);
  if (result.isEmpty()) return next();
  const details = result.array().map((e) => ({ field: e.path, message: e.msg }));
  next(new ValidationError('Validation failed', details));
}

module.exports = { registerRules, loginRules, runValidation };

The custom error classes keep every failure flowing through one handler. Note the ConflictError for a taken email and UnauthorizedError for bad credentials.

// src/errors/AppError.js
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = true;
    Error.captureStackTrace(this, this.constructor);
  }
}
class ValidationError extends AppError {
  constructor(message = 'Validation failed', details = []) {
    super(message, 422);
    this.details = details;
  }
}
class UnauthorizedError extends AppError {
  constructor(message = 'Unauthorized') { super(message, 401); }
}
class ForbiddenError extends AppError {
  constructor(message = 'Forbidden') { super(message, 403); }
}
class ConflictError extends AppError {
  constructor(message = 'Conflict') { super(message, 409); }
}
module.exports = { AppError, ValidationError, UnauthorizedError, ForbiddenError, ConflictError };

Here's the controller. Watch two security habits closely: registration and login both answer with a generic message that never reveals whether an email exists, and login runs verifyPassword even when no user was found is avoided โ€” but we still return the same error either way.

// src/controllers/authController.js
const User = require('../models/User');
const RefreshToken = require('../models/RefreshToken');
const asyncHandler = require('../middleware/asyncHandler');
const token = require('../utils/token');
const env = require('../config/env');
const { UnauthorizedError, ConflictError } = require('../errors/AppError');

const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;   // 7 days, matches REFRESH_TOKEN_TTL

// Set the refresh token as a hardened cookie. This is called on login & refresh.
function setRefreshCookie(res, value) {
  res.cookie('refreshToken', value, {
    httpOnly: true,                 // JavaScript cannot read it โ†’ XSS can't steal it
    secure: env.isProd,             // HTTPS-only in production
    sameSite: 'strict',            // not sent on cross-site requests โ†’ CSRF defense
    path: '/auth',                 // only sent to auth routes, not the whole API
    maxAge: REFRESH_TTL_MS,
  });
}

// Issue a fresh access token + a stored, rotating refresh token.
async function issueTokens(res, user) {
  const jti = token.newTokenId();
  await RefreshToken.create({
    jti, user: user.id, expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
  });
  setRefreshCookie(res, token.signRefreshToken(user, jti));
  return token.signAccessToken(user);
}

// POST /auth/register
exports.register = asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  const existing = await User.findOne({ email });
  if (existing) {
    // Same generic 409 whether or not the email is real would leak nothing,
    // but a taken-email signal is standard on register. Keep the message plain.
    throw new ConflictError('Unable to register with those details');
  }

  const user = new User({ email, password });   // virtual setter โ†’ pre-save hook hashes it
  await user.save();

  // Do NOT auto-login here; make them log in. 201 with no tokens.
  res.status(201).json({ message: 'Registered. Please log in.' });
});

// POST /auth/login
exports.login = asyncHandler(async (req, res) => {
  const { email, password } = req.body;

  // Must opt the hash back in โ€” it's select:false by default.
  const user = await User.findOne({ email }).select('+passwordHash');

  // Generic failure for BOTH "no such user" and "wrong password".
  // Same message + same-ish timing avoids user enumeration.
  const ok = user ? await user.verifyPassword(password) : false;
  if (!ok) throw new UnauthorizedError('Invalid email or password');

  const accessToken = await issueTokens(res, user);
  res.json({ accessToken, user: { id: user.id, email: user.email, role: user.role } });
});

// GET /me  (protected โ€” see Stage 5)
exports.me = asyncHandler(async (req, res) => {
  res.json({ user: req.user });
});

module.exports.setRefreshCookie = setRefreshCookie;
module.exports.issueTokens = issueTokens;

โš ๏ธ Don't help attackers enumerate your users

A tempting-but-dangerous pattern is answering "no account with that email" on a failed login. It's friendlier โ€” and it hands an attacker a free tool to discover which emails are registered, which they'll feed straight into a credential-stuffing run. Always return the same generic "Invalid email or password" for a missing user and a wrong password alike. The same discipline applies to password reset: respond "if that account exists, we sent a link" every time.

Stage 5 โ€” Auth Middleware & RBAC

A protected route needs proof of identity on every request. The requireAuth middleware pulls the access token from the Authorization: Bearer <token> header, verifies its signature and expiry, and attaches the user to req.user so downstream handlers know who's calling. Anything wrong is a flat 401.

// src/middleware/auth.js
const User = require('../models/User');
const { verifyAccessToken } = require('../utils/token');
const { UnauthorizedError, ForbiddenError } = require('../errors/AppError');

// Authentication: "who are you?" โ†’ 401 if we can't tell.
async function requireAuth(req, res, next) {
  try {
    const header = req.headers.authorization || '';
    const [scheme, value] = header.split(' ');
    if (scheme !== 'Bearer' || !value) {
      throw new UnauthorizedError('Missing or malformed Authorization header');
    }

    const payload = verifyAccessToken(value);   // throws if expired/tampered
    const user = await User.findById(payload.sub);
    if (!user) throw new UnauthorizedError('User no longer exists');

    req.user = { id: user.id, email: user.email, role: user.role };
    next();
  } catch (err) {
    // jwt errors (TokenExpiredError, JsonWebTokenError) โ†’ generic 401
    if (err.name === 'TokenExpiredError') {
      return next(new UnauthorizedError('Access token expired'));
    }
    if (err.name === 'JsonWebTokenError') {
      return next(new UnauthorizedError('Invalid access token'));
    }
    next(err);
  }
}

// Authorization: "are you allowed?" โ†’ 403 if your role isn't permitted.
// Usage: router.delete('/users/:id', requireAuth, requireRole('admin'), handler)
function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) return next(new UnauthorizedError());           // not logged in at all
    if (!allowedRoles.includes(req.user.role)) {
      return next(new ForbiddenError('You do not have access to this resource'));
    }
    next();
  };
}

module.exports = { requireAuth, requireRole };

The order in the route chain matters and tells the whole story: authenticate first (who are you?), authorize second (are you allowed?). You can never check a role before you know the user.

// Example wiring (full router in Stage 7):
router.get('/me', requireAuth, authController.me);                       // any logged-in user
router.get('/admin/stats', requireAuth, requireRole('admin'), adminCtrl.stats); // admins only

โœ… 401 vs 403 โ€” say the right "no"

Return 401 Unauthorized when authentication fails: no token, an expired token, a bad signature โ€” "I don't know who you are, log in." Return 403 Forbidden when authentication succeeded but the user lacks permission โ€” "I know exactly who you are, and you still can't do this." Mixing them up confuses clients: a 401 tells the front end to redirect to login, while a 403 tells it to show an "access denied" page. Same rejection, very different UX.

Stage 6 โ€” Refresh, Rotation & Logout

The access token expires every 15 minutes โ€” by design. Rather than forcing a fresh password login four times an hour, the client silently calls /auth/refresh. The browser sends the httpOnly refresh cookie automatically, the server checks it's genuine and not revoked, then rotates it: the old refresh token is retired and a brand-new pair is issued.

Rotation is the security win. If a refresh token is ever stolen and used, its legitimate owner's next refresh will fail (the token was already rotated away) โ€” a detectable signal that something is wrong. And because each token is tracked in the database, logout can revoke it instantly.

// src/controllers/authController.js  (continued)
const { verifyRefreshToken } = require('../utils/token');

// POST /auth/refresh โ€” verify, revoke the old, issue a new pair (rotation).
exports.refresh = asyncHandler(async (req, res) => {
  const raw = req.cookies.refreshToken;
  if (!raw) throw new UnauthorizedError('No refresh token');

  let payload;
  try {
    payload = verifyRefreshToken(raw);          // checks signature + expiry
  } catch {
    throw new UnauthorizedError('Invalid refresh token');
  }

  // The token must exist in our store AND still be active.
  const stored = await RefreshToken.findOne({ jti: payload.jti });
  if (!stored || stored.revoked || stored.expiresAt < new Date()) {
    throw new UnauthorizedError('Refresh token is no longer valid');
  }

  // ROTATE: revoke the one just used so it can never be replayed.
  stored.revoked = true;
  await stored.save();

  const user = await User.findById(payload.sub);
  if (!user) throw new UnauthorizedError('User no longer exists');

  const accessToken = await issueTokens(res, user);   // sets a new refresh cookie
  res.json({ accessToken });
});

// POST /auth/logout โ€” revoke the refresh token and clear the cookie.
exports.logout = asyncHandler(async (req, res) => {
  const raw = req.cookies.refreshToken;
  if (raw) {
    try {
      const { jti } = verifyRefreshToken(raw);
      await RefreshToken.updateOne({ jti }, { revoked: true });  // kill it server-side
    } catch {
      /* invalid/expired token โ†’ nothing to revoke, still clear the cookie */
    }
  }
  res.clearCookie('refreshToken', { path: '/auth' });
  res.status(204).send();     // 204 No Content โ€” logout succeeded, nothing to return
});
Refresh-token rotation: the used token is revoked and a new pair issued access token expires in 15 min refresh token httpOnly cookie, 7 days POST /refresh verify + check store new access + new refresh old refresh revoked โ›” Each refresh retires the token it used โ€” a replayed token is dead on arrival.
Rotation makes a leaked refresh token single-use: the moment it's spent, it's revoked.

๐Ÿ’ก Why a cookie for refresh but a header for access?

They defend against different attacks. The access token goes in an Authorization header set by your JavaScript โ€” convenient, and its short life caps the damage if it leaks. The refresh token is far more valuable (it mints access tokens for a week), so it lives in an httpOnly cookie your JavaScript literally cannot read, putting it out of reach of XSS. Pair that with sameSite: 'strict' and it won't ride along on cross-site requests either, closing the CSRF door.

Stage 7 โ€” Rate Limiting & Wiring It Up

Login is the front door attackers pound on โ€” with leaked password lists (credential stuffing) or brute force. Rate limiting caps how many attempts one client can make in a window, turning millions of guesses per hour into a handful.

// src/middleware/rateLimit.js
const rateLimit = require('express-rate-limit');

// Tight limit on login: a human logs in a few times, a bot tries thousands.
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minutes
  max: 10,                    // 10 attempts per IP per window
  standardHeaders: true,      // send RateLimit-* headers
  legacyHeaders: false,
  message: { error: { message: 'Too many attempts, try again later', status: 429 } },
});

// Slightly looser for registration, still enough to stop mass sign-ups.
const registerLimiter = rateLimit({
  windowMs: 60 * 60 * 1000,   // 1 hour
  max: 20,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: { message: 'Too many accounts created, try again later', status: 429 } },
});

module.exports = { loginLimiter, registerLimiter };

Now assemble the router. Each write endpoint gets its limiter, then validation, then the controller โ€” a clean, readable chain that reads like the rules themselves.

// src/routes/authRoutes.js
const express = require('express');
const ctrl = require('../controllers/authController');
const { requireAuth, requireRole } = require('../middleware/auth');
const { registerRules, loginRules, runValidation } = require('../middleware/validate');
const { loginLimiter, registerLimiter } = require('../middleware/rateLimit');

const router = express.Router();

router.post('/register', registerLimiter, registerRules, runValidation, ctrl.register);
router.post('/login',    loginLimiter,    loginRules,    runValidation, ctrl.login);
router.post('/refresh',  ctrl.refresh);        // guarded by the refresh cookie itself
router.post('/logout',   ctrl.logout);

router.get('/me', requireAuth, ctrl.me);       // any authenticated user

// Role-gated example: only admins may reach this.
router.get('/admin/ping', requireAuth, requireRole('admin'), (req, res) =>
  res.json({ message: 'Hello, admin', you: req.user })
);

module.exports = router;

Finally, the two small support files referenced throughout โ€” the async wrapper and the error handler that formats every failure once.

// src/middleware/asyncHandler.js
module.exports = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);
// src/middleware/errorHandler.js
const { AppError } = require('../errors/AppError');
const env = require('../config/env');

function notFoundHandler(req, res, next) {
  next(new AppError(`Route ${req.method} ${req.originalUrl} not found`, 404));
}

function errorHandler(err, req, res, next) {   // eslint-disable-line no-unused-vars
  const isKnown = err instanceof AppError;
  const statusCode = isKnown ? err.statusCode : 500;

  const body = {
    error: {
      message: isKnown ? err.message : 'Internal Server Error',
      status: statusCode,
    },
  };
  if (err.details) body.error.details = err.details;
  if (!env.isProd) body.error.stack = err.stack;   // hide stacks in production

  if (statusCode >= 500) console.error(err);       // only true surprises get logged
  res.status(statusCode).json(body);
}

module.exports = { notFoundHandler, errorHandler };

โš ๏ธ Rate limiting is a speed bump, not a wall

Per-IP limits are essential but not sufficient โ€” an attacker with a botnet has thousands of IPs, and users behind a shared corporate NAT all look like one IP. Layer your defenses: rate limiting plus strong password rules plus the slow bcrypt hash plus (as a stretch goal) account lockout and CAPTCHA on repeated failures. No single control stops everything; together they make attacks expensive enough to give up on.

Stage 8 โ€” Test With curl

Start the server with npm run dev (add "dev": "nodemon src/server.js" to your scripts) and drive the whole lifecycle from a second terminal. Use -c/-b to save and send the cookie jar so the refresh cookie sticks between calls.

# 1. Register โ†’ expect 201
curl -i -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"Str0ngPass"}'

# 2. Log in โ†’ expect 200 with an accessToken in the body
#    and a Set-Cookie: refreshToken=... ; HttpOnly header. -c saves the cookie.
curl -i -c cookies.txt -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"Str0ngPass"}'

# 3. Call a protected route with the access token โ†’ expect 200
ACCESS="paste_the_accessToken_here"
curl -i http://localhost:3000/auth/me -H "Authorization: Bearer $ACCESS"

# 4. Refresh (sends the saved cookie with -b) โ†’ expect 200 + a NEW accessToken
#    and a new Set-Cookie. -c re-saves the rotated cookie.
curl -i -b cookies.txt -c cookies.txt -X POST http://localhost:3000/auth/refresh

# 5. Log out โ†’ expect 204 and the refresh token revoked
curl -i -b cookies.txt -X POST http://localhost:3000/auth/logout

Now prove the failure paths โ€” the ones that separate a secure system from a leaky one:

# Wrong password โ†’ expect 401 "Invalid email or password"
curl -i -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ada@example.com","password":"wrong"}'

# UNKNOWN email โ†’ expect the SAME 401 message (no user enumeration)
curl -i -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"nobody@example.com","password":"whatever"}'

# Protected route with no token โ†’ expect 401
curl -i http://localhost:3000/auth/me

# Reuse a refresh token AFTER logout/rotation โ†’ expect 401 (it was revoked)
curl -i -b cookies.txt -X POST http://localhost:3000/auth/refresh

โœ… What "correct" looks like

  • The login response body never contains passwordHash โ€” inspect it and confirm.
  • Steps 2 and 4 both send Set-Cookie: refreshToken=...; HttpOnly; SameSite=Strict (and Secure in production).
  • Wrong password and unknown email return the identical 401 message.
  • After logout, the old refresh token yields 401, not a new access token.

Stretch Goals

Nailed the required build with time left? Each of these is a real feature you'll meet in production. Pick whatever excites you โ€” none are needed to pass the rubric.

  • ๐Ÿ“ง Email verification โ€” on register, store a random verificationToken and email a link; block login until isVerified is true. Reuse the generic-response rule so the endpoint never confirms an address exists.
  • ๐Ÿ”‘ Password reset โ€” a /auth/forgot that stores a short-lived, single-use reset token and always replies "if that account exists, we sent a link," and a /auth/reset that consumes the token and re-hashes the new password.
  • ๐ŸŒ OAuth login โ€” add "Sign in with Google/GitHub" with an OAuth 2.0 flow (Passport or Arctic), linking the provider id to a user and issuing your own tokens afterward so the rest of the app is unchanged.
  • ๐Ÿ”’ Account lockout โ€” after N failed logins, lock the account for a cooldown window, layering on top of the IP rate limiter.
  • ๐Ÿงช Automated tests โ€” jest + supertest importing createApp(): assert 201/200/401/403/429 across the happy and unhappy paths (this is why app.js and server.js are split).
  • ๐Ÿ“ Login audit log โ€” record every login attempt (ip, time, success) so you can spot brute-force patterns and give users a "recent activity" view.

Email-verification starter

How little the verification gate takes โ€” a token on the user and one check in login:

// In User.js schema:  isVerified: { type: Boolean, default: false }, verifyToken: String
// In register(): user.verifyToken = crypto.randomBytes(32).toString('hex');
//   then email a link to  /auth/verify/:token  (don't reveal success/failure specifics)
// In login(), before issuing tokens:
if (!user.isVerified) throw new UnauthorizedError('Please verify your email first');

Because it throws the same AppError family, it plugs into your existing error handler with zero new plumbing โ€” the reward, one more time, for centralizing errors early.

Self-Check Rubric

Grade yourself before calling it done. Aim for "yes" across the required column; the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Password storage bcrypt hashing at cost ~12 in a pre-save hook; plaintext never stored, logged, or returned; hash is select: false Configurable cost; upgrades old hashes on login when cost rises
Tokens Short-lived JWT access (~15m) + long-lived refresh; separate secrets; small non-secret payloads Key rotation support; kid header for multiple signing keys
Refresh & logout Refresh rotates (old revoked); logout revokes server-side; revoked/expired tokens rejected with 401 Reuse-detection revokes the whole token family on replay
Middleware & RBAC requireAuth verifies and attaches the user; requireRole gates admin routes with 403; auth before authz Fine-grained permissions or ownership checks beyond roles
Cookies & headers Refresh cookie is httpOnly + secure (prod) + sameSite; helmet mounted first Scoped cookie path; CSP tuned; HSTS in production
Abuse resistance Rate limits on login & register; generic auth errors (no user enumeration); secrets in .env Account lockout; CAPTCHA on repeated failure; audit log

๐Ÿงช Final security checklist

  • โ˜ No response body anywhere contains passwordHash or a raw password
  • โ˜ Wrong password and unknown email return the same 401 message
  • โ˜ A protected route with no/expired/tampered token returns 401
  • โ˜ A non-admin hitting an admin route returns 403 (not 401, not 200)
  • โ˜ After logout or rotation, the old refresh token no longer works
  • โ˜ The refresh cookie shows HttpOnly and SameSite in the Set-Cookie header
  • โ˜ Hammering /auth/login eventually returns 429
  • โ˜ .env is gitignored; no secret is hard-coded in source

Summary

๐ŸŽ‰ What You Built

  • Safe password storage with bcrypt (cost ~12) in a pre-save hook โ€” plaintext never stored, logged, or returned
  • A two-token auth flow: a short-lived JWT access token in the Authorization header and a long-lived refresh token in an httpOnly cookie
  • Auth middleware that verifies the access token and attaches the user, plus role-based authorization that separates 401 from 403
  • Refresh rotation, logout, and revocation backed by a stored-token collection, so a spent or logged-out token is dead on arrival
  • Hardened endpoints: rate limiting on login/register, helmet headers, secure cookie flags, generic errors that resist user enumeration, and secrets in .env

Authentication is where security is won or lost, and you built it the way production systems do โ€” not with a single clever trick but with layers that each cover the others' gaps: slow hashing so a breach isn't game over, short access tokens so a leak self-heals, revocable refresh tokens so you stay in control, role checks so identity and permission never blur, and rate limits so the front door isn't free to pound on. The moves you practiced here โ€” hash, don't store; sign short, rotate long; authenticate then authorize; be generic in your errors โ€” are the everyday grammar of trustworthy backends.

๐Ÿ“š Additional Resources

๐Ÿš€ What's Next?

Your API can now prove who a caller is โ€” but the moment a browser front end on a different origin tries to talk to it, the browser's same-origin policy will block the request, cookies and all. Week 10 opens the full-stack integration phase by solving exactly that: the next lesson, CORS Configuration, shows how to let your trusted front end call this auth API across origins โ€” including the extra care credentials: 'include' requires so those httpOnly cookies actually travel.

๐ŸŽ‰ You finished Week 9!

You built real authentication โ€” hashing, tokens, rotation, RBAC, and hardening. Push it to GitHub (with .env ignored!) and put it in your portfolio; this is the feature every app needs.