Skip to main content
Node.js & Express·Lesson 5 of 5

Authentication

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:

  1. User registers with email and password
  2. Server hashes the password and stores the user
  3. User logs in with credentials
  4. Server verifies credentials and returns a JWT
  5. Client sends the JWT with every subsequent request
  6. Server verifies the JWT and grants access

Setting Up

Install the required packages:

npm install bcrypt jsonwebtoken
  • bcrypt — hashes passwords securely
  • jsonwebtoken — 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:

PartContains
HeaderAlgorithm and token type
PayloadUser data (claims) and expiration
SignatureVerification 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

PracticeWhy
Hash passwords with bcryptProtects against database leaks
Use HTTPS in productionPrevents token interception
Set short JWT expirationLimits damage from stolen tokens
Use httpOnly cookies (optional)Prevents XSS from accessing tokens
Rate limit login attemptsPrevents brute-force attacks
Return generic error messagesPrevents user enumeration
Validate input lengthPrevents 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.