Express.js Authentication with JWT

Authentication verifies who a user is before granting access to protected resources. JSON Web Tokens (JWT) are the most popular authentication method for Express APIs. A JWT is a compact, self-contained token the server creates after a successful login. The client stores it and sends it with every future request, proving its identity without the server maintaining any session state.

How JWT Authentication Works

Think of a JWT like a concert wristband. The venue checks your ticket once at the entrance, puts on a wristband (the token), and from that point on, security staff just checks the wristband — they do not re-verify your original ticket every time you move between areas.

Step 1: Login
  Client → POST /login { email, password }
  Server verifies credentials
  Server creates JWT token and sends it back

Step 2: Access Protected Route
  Client → GET /profile
           Authorization: Bearer eyJhbGci...
  Server verifies the token (no database lookup needed)
  Server sends the protected data

┌─────────────────────────────────────────────────────────┐
│                  JWT Flow Diagram                       │
│                                                         │
│  [Client]  →  POST /login  →  [Express Server]          │
│                                      │                  │
│                              Verify credentials         │
│                                      │                  │
│  [Client]  ←  { token: "eyJ..." }  ←┘                   │
│     │                                                   │
│     │ Stores token in localStorage or httpOnly cookie   │
│     │                                                   │
│  [Client]  →  GET /profile          →  [Express Server] │
│              Authorization: Bearer eyJ...               │
│                                      │                  │
│                              Verify token signature     │
│                                      │                  │
│  [Client]  ←  { name, email, ... }  ←┘                  │
└─────────────────────────────────────────────────────────┘

JWT Structure

A JWT contains three Base64-encoded parts separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9   ← Header
.eyJ1c2VySWQiOiI0MiIsIm5hbWUiOiJBbGljZSJ9  ← Payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c  ← Signature

┌───────────────────────────────────────────────────────┐
│                   JWT Parts                           │
├─────────────────┬─────────────────────────────────────┤
│ Header          │ Algorithm used (HS256, RS256, etc.) │
│ Payload         │ Claims: userId, email, role, expiry │
│ Signature       │ Verifies token wasn't tampered with │
└─────────────────┴─────────────────────────────────────┘

The signature is created by combining the header and payload with your secret key. Any change to the payload breaks the signature, making tampering detectable.

Install Required Packages

npm install jsonwebtoken bcryptjs
  • jsonwebtoken — creates and verifies JWT tokens
  • bcryptjs — hashes passwords so you never store them in plain text

Password Hashing with bcrypt

Never store a plain text password in your database. If your database is ever breached, hashed passwords protect users even then. bcrypt converts a plain password into an unreadable hash:

const bcrypt = require('bcryptjs');

// Hash a password before saving (12 = cost factor, higher = slower = more secure)
const hashPassword = async (plainPassword) => {
  const salt = await bcrypt.genSalt(12);
  const hashed = await bcrypt.hash(plainPassword, salt);
  return hashed;
  // Returns something like: $2a$12$KIXmJHnkv...
};

// Compare during login
const isMatch = await bcrypt.compare(enteredPassword, storedHash);
// Returns true or false

User Model with Password Hashing

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true, minlength: 8, select: false }
});

// Hash password before saving
userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

// Compare password method
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);

Signup Route

const jwt = require('jsonwebtoken');
const User = require('./models/User');

const generateToken = (userId) => {
  return jwt.sign(
    { id: userId },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRY || '7d' }
  );
};

app.post('/api/auth/signup', async (req, res) => {
  try {
    const { name, email, password } = req.body;

    // Check if email already exists
    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ error: 'Email already registered' });
    }

    // Create user (password hashed automatically by pre-save hook)
    const user = await User.create({ name, email, password });

    // Generate token
    const token = generateToken(user._id);

    res.status(201).json({
      success: true,
      token,
      user: { id: user._id, name: user.name, email: user.email }
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Login Route

app.post('/api/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' });
    }

    // Find user and include password field (excluded by default)
    const user = await User.findOne({ email }).select('+password');

    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ error: 'Invalid email or password' });
    }

    const token = generateToken(user._id);

    res.json({
      success: true,
      token,
      user: { id: user._id, name: user.name, email: user.email }
    });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

Auth Middleware: Protect Routes

Create middleware that verifies the token before allowing access to protected routes:

const jwt = require('jsonwebtoken');
const User = require('./models/User');

const protect = async (req, res, next) => {
  try {
    // Get token from Authorization header
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const token = authHeader.split(' ')[1];

    // Verify token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Attach user to request
    req.user = await User.findById(decoded.id);

    if (!req.user) {
      return res.status(401).json({ error: 'User no longer exists' });
    }

    next();
  } catch (err) {
    if (err.name === 'JsonWebTokenError') {
      return res.status(401).json({ error: 'Invalid token' });
    }
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired, please log in again' });
    }
    res.status(500).json({ error: 'Authentication failed' });
  }
};

module.exports = protect;

Applying the Auth Middleware

const protect = require('./middleware/protect');

// Public routes — no token needed
app.post('/api/auth/signup', signupHandler);
app.post('/api/auth/login', loginHandler);

// Protected routes — token required
app.get('/api/profile', protect, (req, res) => {
  res.json({ user: req.user });
});

app.get('/api/orders', protect, (req, res) => {
  res.json({ message: `Orders for user ${req.user._id}` });
});

Role-Based Authorization

Authorization controls what an authenticated user is allowed to do. Use a second middleware after protect to restrict routes by role:

const restrictTo = (...roles) => {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'You do not have permission to perform this action'
      });
    }
    next();
  };
};

// Only admins can access this route
app.delete('/api/users/:id', protect, restrictTo('admin'), deleteUser);

Token Storage Best Practices

┌─────────────────────────────────────────────────────────────────┐
│              Where to Store JWT on the Client                   │
├────────────────────┬────────────────────────────────────────────┤
│ localStorage       │ Easy but vulnerable to XSS attacks         │
│ sessionStorage     │ Same as localStorage, cleared on tab close │
│ httpOnly Cookie    │ Most secure — JavaScript cannot access it  │
│                    │ Automatically sent with every request      │
└────────────────────┴────────────────────────────────────────────┘

For maximum security, send the JWT as an httpOnly cookie instead of in the response body:

res.cookie('jwt', token, {
  httpOnly: true,        // Not accessible by JavaScript
  secure: process.env.NODE_ENV === 'production', // HTTPS only in production
  sameSite: 'strict',   // Prevents CSRF
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
});

Summary

JWT authentication works in two phases: login creates and returns a token, and subsequent requests include that token to prove identity. Install jsonwebtoken and bcryptjs. Hash passwords before saving with bcrypt and never store plain text. On login, verify the password with bcrypt.compare() and sign a JWT with jwt.sign(). Create a protect middleware that reads the token from the Authorization header, verifies it with jwt.verify(), and attaches the user to req.user. Apply this middleware to protected routes. Add role-based authorization with a restrictTo() middleware for admin or privileged actions. Store tokens in httpOnly cookies for the best client-side security.

Leave a Comment

Your email address will not be published. Required fields are marked *