Authentication verifies who a user is. In this lesson, you will build a complete authentication system with registration, login, password hashing, JSON Web Tokens (JWT), and protected routes.
Authentication Flow
A typical token-based authentication flow:
- User registers with email and password
- Server hashes the password and stores the user
- User logs in with credentials
- Server verifies credentials and returns a JWT
- Client sends the JWT with every subsequent request
- Server verifies the JWT and grants access
Setting Up
Install the required packages:
npm install bcrypt jsonwebtokenbcrypt— hashes passwords securelyjsonwebtoken— creates and verifies JWTs
Password Hashing
Never store plain-text passwords. Use bcrypt to hash them:
const bcrypt = require("bcrypt");
const SALT_ROUNDS = 12;
// Hash a password
async function hashPassword(plainText) {
return bcrypt.hash(plainText, SALT_ROUNDS);
}
// Verify a password
async function verifyPassword(plainText, hash) {
return bcrypt.compare(plainText, hash);
}
// Usage
const hashed = await hashPassword("my-secret-password");
console.log(hashed);
// "$2b$12$LJ3m4ys3Gz..."
const isValid = await verifyPassword("my-secret-password", hashed);
console.log(isValid); // true
Higher salt rounds mean slower hashing (more secure but slower). 12 is a good default.
JSON Web Tokens
A JWT contains a header, payload, and signature. The server signs the token with a secret key, and can later verify it without a database lookup.
const jwt = require("jsonwebtoken");
const JWT_SECRET = process.env.JWT_SECRET || "dev-secret-change-me";
const JWT_EXPIRES_IN = "7d";
function generateToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
}
function verifyToken(token) {
return jwt.verify(token, JWT_SECRET);
}JWT Structure
A JWT looks like xxxxx.yyyyy.zzzzz — three base64-encoded parts separated by dots:
| Part | Contains |
|---|---|
| Header | Algorithm and token type |
| Payload | User data (claims) and expiration |
| Signature | Verification hash (header + payload + secret) |
User Registration
// src/routes/auth.js
const express = require("express");
const router = express.Router();
const bcrypt = require("bcrypt");
const db = require("../db");
const { generateToken } = require("../utils/jwt");
router.post("/register", async (req, res) => {
const { name, email, password } = req.body;
// Validate input
if (!name || !email || !password) {
return res.status(400).json({
error: "Name, email, and password are required",
});
}
if (password.length < 8) {
return res.status(400).json({
error: "Password must be at least 8 characters",
});
}
// Check if email is taken
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
return res.status(409).json({ error: "Email already registered" });
}
// Hash password and create user
const hashedPassword = await bcrypt.hash(password, 12);
const result = db
.prepare("INSERT INTO users (name, email, password) VALUES (?, ?, ?)")
.run(name, email, hashedPassword);
const user = db
.prepare("SELECT id, name, email FROM users WHERE id = ?")
.get(result.lastInsertRowid);
const token = generateToken(user);
res.status(201).json({ user, token });
});User Login
router.post("/login", async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
// Find user by email
const user = db
.prepare("SELECT * FROM users WHERE email = ?")
.get(email);
if (!user) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ error: "Invalid credentials" });
}
// Generate token
const token = generateToken({
id: user.id,
email: user.email,
role: user.role,
});
// Return user without password
const { password: _, ...userWithoutPassword } = user;
res.json({ user: userWithoutPassword, token });
});Notice we return the same error message for both "user not found" and "wrong password." This prevents attackers from discovering which emails are registered.
Auth Middleware
Protect routes by verifying the JWT on every request:
// src/middleware/auth.js
const { verifyToken } = require("../utils/jwt");
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.split(" ")[1];
try {
const decoded = verifyToken(token);
req.user = decoded;
next();
} catch (error) {
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(401).json({ error: "Invalid token" });
}
}
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
module.exports = { authenticate, authorize };Protecting Routes
const { authenticate, authorize } = require("../middleware/auth");
// Any authenticated user
router.get("/profile", authenticate, (req, res) => {
const user = db
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
.get(req.user.id);
res.json(user);
});
// Admin only
router.get(
"/admin/users",
authenticate,
authorize("admin"),
(req, res) => {
const users = db
.prepare("SELECT id, name, email, role FROM users")
.all();
res.json(users);
}
);
// Update own profile
router.put("/profile", authenticate, async (req, res) => {
const { name } = req.body;
db.prepare("UPDATE users SET name = ? WHERE id = ?").run(name, req.user.id);
const user = db
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
.get(req.user.id);
res.json(user);
});Security Best Practices
Rate Limit Login Attempts
const loginAttempts = new Map();
function loginRateLimit(req, res, next) {
const ip = req.ip;
const now = Date.now();
const windowMs = 15 * 60 * 1000; // 15 minutes
const maxAttempts = 5;
const attempts = (loginAttempts.get(ip) || []).filter(
(t) => t > now - windowMs
);
if (attempts.length >= maxAttempts) {
return res.status(429).json({
error: "Too many login attempts. Try again in 15 minutes.",
});
}
attempts.push(now);
loginAttempts.set(ip, attempts);
next();
}
router.post("/login", loginRateLimit, async (req, res) => {
// ... login logic
});Security Checklist
| Practice | Why |
|---|---|
| Hash passwords with bcrypt | Protects against database leaks |
| Use HTTPS in production | Prevents token interception |
| Set short JWT expiration | Limits damage from stolen tokens |
| Use httpOnly cookies (optional) | Prevents XSS from accessing tokens |
| Rate limit login attempts | Prevents brute-force attacks |
| Return generic error messages | Prevents user enumeration |
| Validate input length | Prevents DoS via long passwords |
Practical Exercise
Wire everything together into a complete auth system:
// src/app.js
const express = require("express");
const app = express();
app.use(express.json());
// Public routes
app.use("/api/auth", require("./routes/auth"));
// Protected routes
const { authenticate } = require("./middleware/auth");
app.use("/api/users", authenticate, require("./routes/users"));
// Test the flow:
// 1. POST /api/auth/register { name, email, password }
// 2. POST /api/auth/login { email, password }
// 3. GET /api/users (with Authorization: Bearer <token>)
Key Takeaways
- Never store plain-text passwords — always hash with bcrypt.
- JWTs let you verify identity without a database lookup on every request.
- Use middleware to protect routes and separate auth logic from business logic.
- Return the same error for "user not found" and "wrong password" to prevent user enumeration.
- Rate limit login attempts and use HTTPS in production.