Project: Secure Auth System
Checking access...
Build a production-ready authentication system combining everything from this module: JWT tokens, refresh token rotation, rate limiting, security headers, input validation, and a React login UI.
Project Overview
Stack: Express.js + JWT + React + Helmet + express-rate-limit + Joi
Features:
- Register with email/password (validated + hashed)
- Login returns access token (15min) + refresh token (7 days)
- Refresh token rotation (old token invalidated on each refresh)
- Rate limiting on auth endpoints
- Security headers via Helmet
- Input validation on all inputs
- React login/register UI
- Protected dashboard page
Project Structure
secure-auth/├── server/│ ├── package.json│ ├── src/│ │ ├── index.js│ │ ├── config/│ │ │ ├── db.js│ │ │ └── env.js│ │ ├── middleware/│ │ │ ├── auth.js # JWT verification│ │ │ └── validate.js # Joi validation│ │ ├── routes/│ │ │ └── auth.js│ │ ├── models/│ │ │ └── User.js│ │ └── utils/│ │ └── tokens.js # JWT + refresh token logic│ └── .env├── client/│ ├── package.json│ ├── index.html│ └── src/│ ├── App.jsx│ ├── api.js│ ├── AuthContext.jsx│ └── components/│ ├── Login.jsx│ ├── Register.jsx│ └── Dashboard.jsx└── README.mdStep 1: Server Setup
package.json
{ "name": "secure-auth-server", "private": true, "scripts": { "dev": "node --watch src/index.js", "start": "node src/index.js" }, "dependencies": { "bcrypt": "^5.1.1", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", "express-rate-limit": "^7.4.1", "helmet": "^8.0.0", "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "uuid": "^10.0.0" }}Environment Variables
PORT=3001JWT_ACCESS_SECRET=your-access-secret-min-32-chars-longJWT_REFRESH_SECRET=your-refresh-secret-different-from-accessACCESS_TOKEN_EXPIRY=15mREFRESH_TOKEN_EXPIRY=7dFRONTEND_URL=http://localhost:5173Database (In-Memory for Simplicity)
// In production, replace with MongoDB, PostgreSQL, etc.
const users = new Map(); // email → { id, name, email, passwordHash }const refreshTokens = new Map(); // tokenId → { userId, expiresAt }
function createUser({ name, email, passwordHash }) { const id = crypto.randomUUID(); const user = { id, name, email, passwordHash, createdAt: new Date() }; users.set(email, user); return user;}
function findUserByEmail(email) { return users.get(email) || null;}
function findUserById(id) { for (const user of users.values()) { if (user.id === id) return user; } return null;}
function saveRefreshToken(tokenId, userId, expiresAt) { refreshTokens.set(tokenId, { userId, expiresAt });}
function findRefreshToken(tokenId) { return refreshTokens.get(tokenId) || null;}
function deleteRefreshToken(tokenId) { refreshTokens.delete(tokenId);}
function deleteUserRefreshTokens(userId) { for (const [id, token] of refreshTokens) { if (token.userId === userId) refreshTokens.delete(id); }}
module.exports = { createUser, findUserByEmail, findUserById, saveRefreshToken, findRefreshToken, deleteRefreshToken, deleteUserRefreshTokens,};Step 2: Token Utilities
const jwt = require("jsonwebtoken");const crypto = require("crypto");
function generateAccessToken(user) { return jwt.sign( { sub: user.id, email: user.email, name: user.name, }, process.env.JWT_ACCESS_SECRET, { expiresIn: process.env.ACCESS_TOKEN_EXPIRY || "15m" } );}
function verifyAccessToken(token) { try { return jwt.verify(token, process.env.JWT_ACCESS_SECRET); } catch (err) { if (err.name === "TokenExpiredError") { return null; // Expired — frontend should refresh } return null; // Invalid }}
function generateRefreshToken(userId) { const tokenId = crypto.randomUUID(); const expiresAt = new Date( Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days );
return { tokenId, expiresAt };}
module.exports = { generateAccessToken, verifyAccessToken, generateRefreshToken,};Step 3: Validation Middleware
const Joi = require("joi");
const schemas = { register: Joi.object({ name: Joi.string().trim().min(2).max(50).required(), email: Joi.string().email().lowercase().trim().required(), password: Joi.string() .min(8) .max(128) .pattern( /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, "Password must contain at least one uppercase letter, one lowercase letter, and one number" ) .required(), }),
login: Joi.object({ email: Joi.string().email().lowercase().trim().required(), password: Joi.string().required(), }),
refresh: Joi.object({ refreshToken: Joi.string().uuid().required(), }),};
function validate(schemaName) { return (req, res, next) => { const schema = schemas[schemaName]; if (!schema) { return res.status(500).json({ error: "Schema not found" }); }
const { error, value } = schema.validate(req.body, { abortEarly: false, stripUnknown: true, });
if (error) { return res.status(422).json({ error: "VALIDATION_ERROR", details: error.details.map((d) => ({ field: d.path.join("."), message: d.message, })), }); }
req.body = value; next(); };}
module.exports = { validate };Step 4: Auth Middleware
const { verifyAccessToken } = require("../utils/tokens");
function authenticate(req, res, next) { const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) { return res.status(401).json({ error: "AUTH_REQUIRED", message: "Access token required", }); }
const token = authHeader.split(" ")[1]; const payload = verifyAccessToken(token);
if (!payload) { return res.status(401).json({ error: "TOKEN_INVALID", message: "Access token expired or invalid", }); }
req.user = { id: payload.sub, email: payload.email, name: payload.name, };
next();}
module.exports = { authenticate };Step 5: Auth Routes
const express = require("express");const bcrypt = require("bcrypt");const router = express.Router();
const { authenticate } = require("../middleware/auth");const { validate } = require("../middleware/validate");const { generateAccessToken, generateRefreshToken, verifyAccessToken,} = require("../utils/tokens");const db = require("../config/db");
// POST /api/auth/registerrouter.post("/register", validate("register"), async (req, res) => { try { const { name, email, password } = req.body;
// Check if user exists if (db.findUserByEmail(email)) { return res.status(409).json({ error: "EMAIL_EXISTS", message: "An account with this email already exists", }); }
// Hash password const salt = await bcrypt.genSalt(12); const passwordHash = await bcrypt.hash(password, salt);
// Create user const user = db.createUser({ name, email, passwordHash });
// Generate tokens const accessToken = generateAccessToken(user); const { tokenId, expiresAt } = generateRefreshToken(user.id); db.saveRefreshToken(tokenId, user.id, expiresAt);
res.status(201).json({ user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken: tokenId, expiresAt, }); } catch (err) { console.error("Register error:", err); res.status(500).json({ error: "INTERNAL_ERROR", message: "Registration failed" }); }});
// POST /api/auth/loginrouter.post("/login", validate("login"), async (req, res) => { try { const { email, password } = req.body;
const user = db.findUserByEmail(email); if (!user) { return res.status(401).json({ error: "INVALID_CREDENTIALS", message: "Invalid email or password", }); }
const validPassword = await bcrypt.compare(password, user.passwordHash); if (!validPassword) { return res.status(401).json({ error: "INVALID_CREDENTIALS", message: "Invalid email or password", }); }
// Generate tokens const accessToken = generateAccessToken(user); const { tokenId, expiresAt } = generateRefreshToken(user.id); db.saveRefreshToken(tokenId, user.id, expiresAt);
res.json({ user: { id: user.id, name: user.name, email: user.email }, accessToken, refreshToken: tokenId, expiresAt, }); } catch (err) { console.error("Login error:", err); res.status(500).json({ error: "INTERNAL_ERROR", message: "Login failed" }); }});
// POST /api/auth/refreshrouter.post("/refresh", validate("refresh"), (req, res) => { try { const { refreshToken } = req.body;
const stored = db.findRefreshToken(refreshToken); if (!stored) { return res.status(401).json({ error: "REFRESH_INVALID", message: "Refresh token not found", }); }
// Check expiration if (new Date() > new Date(stored.expiresAt)) { db.deleteRefreshToken(refreshToken); return res.status(401).json({ error: "REFRESH_EXPIRED", message: "Refresh token expired. Please log in again.", }); }
const user = db.findUserById(stored.userId); if (!user) { db.deleteRefreshToken(refreshToken); return res.status(401).json({ error: "USER_NOT_FOUND", message: "User no longer exists", }); }
// ROTATE: delete old, issue new db.deleteRefreshToken(refreshToken);
const newAccessToken = generateAccessToken(user); const { tokenId: newTokenId, expiresAt: newExpires } = generateRefreshToken(user.id); db.saveRefreshToken(newTokenId, user.id, newExpires);
res.json({ accessToken: newAccessToken, refreshToken: newTokenId, expiresAt: newExpires, }); } catch (err) { console.error("Refresh error:", err); res.status(500).json({ error: "INTERNAL_ERROR", message: "Token refresh failed" }); }});
// POST /api/auth/logoutrouter.post("/logout", (req, res) => { const { refreshToken } = req.body;
if (refreshToken) { db.deleteRefreshToken(refreshToken); }
res.json({ message: "Logged out successfully" });});
// GET /api/auth/merouter.get("/me", authenticate, (req, res) => { res.json({ user: req.user });});
module.exports = router;Step 6: Server Entry Point
require("dotenv").config();
const express = require("express");const cors = require("cors");const helmet = require("helmet");const rateLimit = require("express-rate-limit");const authRoutes = require("./routes/auth");
const app = express();const PORT = process.env.PORT || 3001;
// Security headersapp.use(helmet());
// CORSapp.use( cors({ origin: process.env.FRONTEND_URL || "http://localhost:5173", credentials: true, }));
// Body parsingapp.use(express.json({ limit: "10kb" }));
// Rate limitingconst authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // 10 attempts per window message: { error: "RATE_LIMITED", message: "Too many attempts. Please try again later.", }, standardHeaders: true, legacyHeaders: false,});
// Apply rate limiter to auth routesapp.use("/api/auth", authLimiter);
// Routesapp.use("/api/auth", authRoutes);
// Health checkapp.get("/api/health", (req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() });});
// Global error handlerapp.use((err, req, res, next) => { console.error("Unhandled error:", err); res.status(500).json({ error: "INTERNAL_ERROR", message: "Something went wrong", });});
app.listen(PORT, () => { console.log(`Auth server running on http://localhost:${PORT}`);});Step 7: React Frontend
Auth Context
import { createContext, useContext, useState, useEffect, useCallback } from "react";
const API = "http://localhost:3001/api/auth";
const AuthContext = createContext(null);
export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true);
// Try to restore session from stored tokens useEffect(() => { const accessToken = localStorage.getItem("accessToken"); const refreshToken = localStorage.getItem("refreshToken");
if (accessToken && refreshToken) { // Verify token is still valid fetch(`${API}/me`, { headers: { Authorization: `Bearer ${accessToken}` }, }) .then((res) => { if (res.ok) return res.json(); throw new Error("Session expired"); }) .then((data) => setUser(data.user)) .catch(() => { // Token expired — try refresh return refreshSession(); }) .finally(() => setLoading(false)); } else { setLoading(false); } }, []);
const refreshSession = useCallback(async () => { const storedRefresh = localStorage.getItem("refreshToken"); if (!storedRefresh) return;
try { const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken: storedRefresh }), });
if (res.ok) { const data = await res.json(); localStorage.setItem("accessToken", data.accessToken); localStorage.setItem("refreshToken", data.refreshToken);
// Fetch user data const userRes = await fetch(`${API}/me`, { headers: { Authorization: `Bearer ${data.accessToken}` }, }); if (userRes.ok) { const userData = await userRes.json(); setUser(userData.user); } } else { logout(); } } catch { logout(); } }, []);
const login = async (email, password) => { const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), });
const data = await res.json(); if (!res.ok) throw new Error(data.message);
localStorage.setItem("accessToken", data.accessToken); localStorage.setItem("refreshToken", data.refreshToken); setUser(data.user); return data; };
const register = async (name, email, password) => { const res = await fetch(`${API}/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, email, password }), });
const data = await res.json(); if (!res.ok) throw new Error(data.message);
localStorage.setItem("accessToken", data.accessToken); localStorage.setItem("refreshToken", data.refreshToken); setUser(data.user); return data; };
const logout = () => { const refreshToken = localStorage.getItem("refreshToken"); if (refreshToken) { fetch(`${API}/logout`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }).catch(() => {}); }
localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); setUser(null); };
return ( <AuthContext.Provider value={{ user, loading, login, register, logout }}> {children} </AuthContext.Provider> );}
export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error("useAuth must be used within AuthProvider"); return context;}Login Component
import { useState } from "react";import { useAuth } from "../AuthContext";
export default function Login() { const { login } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => { e.preventDefault(); setError(""); setLoading(true);
try { await login(email, password); } catch (err) { setError(err.message); } finally { setLoading(false); } };
return ( <div className="auth-form"> <h2>Sign In</h2> {error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}> <div> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus /> </div>
<div> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} /> </div>
<button type="submit" disabled={loading}> {loading ? "Signing in..." : "Sign In"} </button> </form> </div> );}Register Component
import { useState } from "react";import { useAuth } from "../AuthContext";
export default function Register() { const { register } = useAuth(); const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => { e.preventDefault(); setError(""); setLoading(true);
try { await register(name, email, password); } catch (err) { setError(err.message); } finally { setLoading(false); } };
return ( <div className="auth-form"> <h2>Create Account</h2> {error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}> <div> <label htmlFor="name">Name</label> <input id="name" type="text" value={name} onChange={(e) => setName(e.target.value)} required minLength={2} /> </div>
<div> <label htmlFor="email">Email</label> <input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div>
<div> <label htmlFor="password">Password</label> <input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} /> <small>At least 8 characters with uppercase, lowercase, and a number</small> </div>
<button type="submit" disabled={loading}> {loading ? "Creating account..." : "Create Account"} </button> </form> </div> );}Dashboard Component
import { useAuth } from "../AuthContext";
export default function Dashboard() { const { user, logout } = useAuth();
return ( <div className="dashboard"> <div className="dashboard-header"> <h1>Welcome, {user.name}!</h1> <button onClick={logout} className="logout-btn"> Sign Out </button> </div>
<div className="dashboard-card"> <h3>Profile</h3> <p><strong>ID:</strong> {user.id}</p> <p><strong>Email:</strong> {user.email}</p> <p><strong>Name:</strong> {user.name}</p> </div>
<div className="dashboard-card"> <h3>Security Status</h3> <ul> <li>✅ Rate limiting active — 10 requests per 15 minutes</li> <li>✅ Security headers via Helmet</li> <li>✅ Input validation on all endpoints</li> <li>✅ Refresh token rotation enabled</li> <li>✅ Password hashed with bcrypt (12 rounds)</li> </ul> </div> </div> );}App Component
import { useState } from "react";import { useAuth } from "./AuthContext";import Login from "./components/Login";import Register from "./components/Register";import Dashboard from "./components/Dashboard";
export default function App() { const { user, loading } = useAuth(); const [view, setView] = useState("login");
if (loading) { return <div className="loading">Loading...</div>; }
if (user) { return <Dashboard />; }
return ( <div className="app"> <nav> <button onClick={() => setView("login")} className={view === "login" ? "active" : ""} > Sign In </button> <button onClick={() => setView("register")} className={view === "register" ? "active" : ""} > Register </button> </nav>
{view === "login" ? <Login /> : <Register />} </div> );}API Client
const API = "http://localhost:3001/api/auth";
async function fetchWithAuth(endpoint, options = {}) { const accessToken = localStorage.getItem("accessToken");
const res = await fetch(`${API}${endpoint}`, { ...options, headers: { "Content-Type": "application/json", ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), ...options.headers, }, });
if (res.status === 401) { // Try refresh const refreshToken = localStorage.getItem("refreshToken"); if (refreshToken) { const refreshRes = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), });
if (refreshRes.ok) { const data = await refreshRes.json(); localStorage.setItem("accessToken", data.accessToken); localStorage.setItem("refreshToken", data.refreshToken);
// Retry original request return fetch(`${API}${endpoint}`, { ...options, headers: { "Content-Type": "application/json", Authorization: `Bearer ${data.accessToken}`, ...options.headers, }, }); }
// Refresh failed — force logout localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); window.location.reload(); } }
return res;}
export { fetchWithAuth };Step 8: Testing
Manual Test Flow
# 1. Start servercd server && npm run dev
# 2. Register a usercurl -X POST http://localhost:3001/api/auth/register \ -H "Content-Type: application/json" \ -d '{ "name": "Test User", "email": "test@example.com", "password": "TestPass123!" }'
# 3. Logincurl -X POST http://localhost:3001/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email": "test@example.com", "password": "TestPass123!"}'
# 4. Access protected route (use token from step 3)curl http://localhost:3001/api/auth/me \ -H "Authorization: Bearer <access_token>"
# 5. Refresh tokencurl -X POST http://localhost:3001/api/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refreshToken": "<refresh_token>"}'
# 6. Test rate limiting (repeat login quickly)for i in {1..15}; do curl -s -o /dev/null -w "%{http_code}\n" \ -X POST http://localhost:3001/api/auth/login \ -H "Content-Type: application/json" \ -d '{"email":"test@test.com","password":"wrong"}'done# After ~10 attempts, should see 429
# 7. Test validationcurl -X POST http://localhost:3001/api/auth/register \ -H "Content-Type: application/json" \ -d '{"name": "A", "email": "bad", "password": "123"}'# Should return 422 with validation detailsChallenge: Add These Features
Email verification: Add a
verifiedfield to users. Send a verification email with a signed token link. Only allow login for verified accounts.Password reset flow: Implement a “forgot password” endpoint that generates a one-time reset token (expires in 15 minutes). Add a React form for entering the new password.
Admin roles: Add an
adminflag to users. Create an admin-only middleware and an admin dashboard page. Protect routes based on role.Persist with SQLite: Replace the in-memory database with
better-sqlite3. Create a users table and a refresh_tokens table. Run migrations on startup.