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.
| Method | Route | Purpose | Auth required |
|---|---|---|---|
POST | /auth/register | Create an account | No |
POST | /auth/login | Verify credentials, issue tokens | No |
POST | /auth/refresh | Swap a refresh token for a new access token | No (refresh token) |
POST | /auth/logout | Revoke a refresh token | No (refresh token) |
GET | /me | Return the current user | Yes (access token) |
POST | /auth/forgot-password | Email a reset token | No |
POST | /auth/reset-password | Set a new password | No (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/Setfor a real database (PostgreSQL or MongoDB) so data and tokens survive restarts. - Add email verification before activating new accounts.
- Add rate limiting on
/auth/loginand/auth/forgot-passwordto slow brute-force attacks. - Send refresh tokens as
httpOnly,Secure,SameSitecookies instead of JSON to mitigate XSS theft. - Add role-based authorization (e.g.
adminvsuser) on top ofrequireAuth.
Best Practices
- Always hash passwords with
bcrypt(orargon2) 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.