๐ 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
admincan reach admin routes โ separate from authentication - Harden auth endpoints with rate limiting, helmet security headers, and
httpOnly+secure+sameSitecookie 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.
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()vsnext(err), and a centralized 4-argument error handler - async/await & custom error classes โ
class MyError extends Errorcarrying anerr.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, and429
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
httpOnlycookie - โ 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
requireRolemiddleware returning403 - โ Rate limiting on
/auth/loginand/auth/registerto 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: falseplus thetoJSONtransform 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
});
๐ก 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(andSecurein 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
verificationTokenand email a link; block login untilisVerifiedis true. Reuse the generic-response rule so the endpoint never confirms an address exists. - ๐ Password reset โ a
/auth/forgotthat stores a short-lived, single-use reset token and always replies "if that account exists, we sent a link," and a/auth/resetthat 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+supertestimportingcreateApp(): assert 201/200/401/403/429 across the happy and unhappy paths (this is whyapp.jsandserver.jsare 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.
| Area | Meets 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
passwordHashor 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
HttpOnlyandSameSitein theSet-Cookieheader - โ Hammering
/auth/logineventually returns 429 - โ
.envis 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
Authorizationheader and a long-lived refresh token in anhttpOnlycookie - 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,
helmetheaders, 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
- OWASP โ Authentication Cheat Sheet
- OWASP โ Password Storage Cheat Sheet
- bcrypt โ npm package docs
- jsonwebtoken โ API reference
- Helmet โ security headers for Express
- MDN โ Set-Cookie and cookie security flags
๐ 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.