Skip to main content

Skillber v1.0 is here!

Learn more

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.md

Step 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

.env
PORT=3001
JWT_ACCESS_SECRET=your-access-secret-min-32-chars-long
JWT_REFRESH_SECRET=your-refresh-secret-different-from-access
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
FRONTEND_URL=http://localhost:5173

Database (In-Memory for Simplicity)

src/config/db.js
// 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

src/utils/tokens.js
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

src/middleware/validate.js
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

src/middleware/auth.js
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

src/routes/auth.js
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/register
router.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/login
router.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/refresh
router.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/logout
router.post("/logout", (req, res) => {
const { refreshToken } = req.body;
if (refreshToken) {
db.deleteRefreshToken(refreshToken);
}
res.json({ message: "Logged out successfully" });
});
// GET /api/auth/me
router.get("/me", authenticate, (req, res) => {
res.json({ user: req.user });
});
module.exports = router;

Step 6: Server Entry Point

src/index.js
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 headers
app.use(helmet());
// CORS
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:5173",
credentials: true,
})
);
// Body parsing
app.use(express.json({ limit: "10kb" }));
// Rate limiting
const 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 routes
app.use("/api/auth", authLimiter);
// Routes
app.use("/api/auth", authRoutes);
// Health check
app.get("/api/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Global error handler
app.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

client/src/AuthContext.jsx
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

client/src/components/Login.jsx
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

client/src/components/Register.jsx
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

client/src/components/Dashboard.jsx
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

client/src/App.jsx
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

client/src/api.js
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

Terminal window
# 1. Start server
cd server && npm run dev
# 2. Register a user
curl -X POST http://localhost:3001/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"name": "Test User",
"email": "test@example.com",
"password": "TestPass123!"
}'
# 3. Login
curl -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 token
curl -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 validation
curl -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 details

Challenge: Add These Features

  1. Email verification: Add a verified field to users. Send a verification email with a signed token link. Only allow login for verified accounts.

  2. 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.

  3. Admin roles: Add an admin flag to users. Create an admin-only middleware and an admin dashboard page. Protect routes based on role.

  4. Persist with SQLite: Replace the in-memory database with better-sqlite3. Create a users table and a refresh_tokens table. Run migrations on startup.