13 Topics
Chapter•65 Minutes

Implement JWT Authentication and Authorization

Implement secure user registration, password hashing, login, JWT access tokens, authentication middleware, and role-based authorization.

Authentication vs Authorization

Authentication and authorization are two different security concepts.

Authentication determines who the user is.

For example, a user enters an email and password. The backend verifies those credentials and establishes an authenticated identity.

Authorization determines what the authenticated user is allowed to do.

For example:

  • A normal user can view their own profile.
  • An administrator can view all users.
  • An administrator can delete accounts.

A user may be authenticated but still not have permission to perform a particular operation.

Our application will implement JWT authentication and role-based authorization.

Authentication vs Authorization

FeatureAuthenticationAuthorization
PurposeVerify identityVerify permissions
ExampleUser loginAdmin-only endpoint
WhenBefore granting authenticated accessAfter identity is established
Failure401 Unauthorized403 Forbidden
Key Takeaways
  • Authentication verifies identity.

  • Authorization checks permissions.

  • A valid login does not automatically grant administrative access.

Understand JSON Web Tokens

JSON Web Token, commonly called JWT, is a compact token format used to transfer claims between parties.

A signed JWT contains three sections separated by dots:

  1. Header.
  2. Payload.
  3. Signature.

Example structure:

header.payload.signature

The header describes the token type and signing algorithm.

The payload contains claims such as:

  • sub: Subject or user identifier.
  • iat: Issued-at timestamp.
  • exp: Expiration timestamp.
  • iss: Issuer.
  • aud: Audience.

The signature allows the receiving server to verify that the token has not been modified.

A signed JWT is not automatically encrypted. Its payload can generally be decoded by anyone who possesses the token.

Never store passwords, API secrets, or sensitive personal information inside an ordinary signed JWT payload.

JWT Authentication Flow
Key Takeaways
  • JWT contains a header, payload, and signature.

  • Signed JWT payloads are not automatically encrypted.

  • Verify the token signature and claims before trusting it.

Hash Passwords Before Storing Them

Passwords must never be stored as plain text.

If a database is compromised, plain-text passwords expose user accounts immediately.

Password hashing transforms a password into a one-way hash.

We will use bcryptjs to hash passwords before saving users.

bcrypt uses a configurable work factor to make password guessing more expensive.

For this tutorial, we will use a cost factor of 12.

The password should be hashed inside the registration service, not in the controller.

Never Store Plain-Text Passwords

Only store password hashes. Never log passwords, include them in API responses, or send them to analytics systems.

Hash a Password
import bcrypt from 'bcryptjs';

const password = 'SecurePassword123!';

const passwordHash = await bcrypt.hash(password, 12);

console.log(passwordHash);

Generates a bcrypt password hash using a cost factor of 12. The same plaintext password produces different hashes because bcrypt uses a salt.

Key Takeaways
  • Hash passwords before database storage.

  • bcrypt includes a salt in the hashing process.

  • Password hashes should never be returned to clients.

Implement Secure User Registration

Update the user registration service.

The registration process should:

  1. Normalize the email address.
  2. Check whether the email already exists.
  3. Hash the password.
  4. Create the user.
  5. Return the user without the password field.

The unique database index remains necessary because checking for an existing email before insertion does not prevent concurrent registration attempts.

Secure User Registration Service
import bcrypt from 'bcryptjs';
import { User } from '../models/user.model.js';
import { AppError } from '../utils/app-error.js';

export async function registerUser(userData) {
  const email = userData.email.toLowerCase().trim();

  const existingUser = await User.findOne({ email });

  if (existingUser) {
    throw new AppError('Email is already registered', 409);
  }

  const passwordHash = await bcrypt.hash(userData.password, 12);

  const user = await User.create({
    name: userData.name,
    email,
    password: passwordHash,
    age: userData.age
  });

  return User.findById(user._id).select('-password');
}

Normalizes email, checks for an existing account, hashes the password, creates the user, and returns a password-free document.

Key Takeaways
  • Normalize email addresses before storage.

  • Hash passwords before creating users.

  • Handle duplicate-key errors because pre-checks alone are insufficient.

Generate JWT Access Tokens

After successful authentication, generate an access token for the user.

The token should contain only the claims needed by the API.

We will use:

  • sub: User ID.
  • iss: Token issuer.
  • aud: Intended audience.
  • exp: Expiration.

Create a token utility inside src/utils.

The token secret must come from environment configuration.

Token Expiration

Short-lived access tokens reduce the impact of token theft. A production application may also implement refresh token rotation and revocation.

src/utils/jwt.js
import jwt from 'jsonwebtoken';

export function generateAccessToken(user) {
  if (!process.env.JWT_SECRET) {
    throw new Error('JWT_SECRET is not configured');
  }

  return jwt.sign(
    {},
    process.env.JWT_SECRET,
    {
      algorithm: 'HS256',
      subject: user._id.toString(),
      issuer: 'rest-api',
      audience: 'rest-api-client',
      expiresIn: process.env.JWT_EXPIRES_IN || '15m'
    }
  );
}

Generates a signed JWT with a user subject, issuer, audience, algorithm, and expiration.

Key Takeaways
  • JWT signing creates a verifiable access token.

  • Use explicit algorithm, issuer, audience, and expiration settings.

  • Never expose the signing secret.

Implement User Login

The login service verifies user credentials.

The process is:

  1. Find the user by normalized email.
  2. Explicitly select the password field because it is excluded by default.
  3. Compare the submitted password with the stored hash.
  4. Reject invalid credentials.
  5. Generate an access token.
  6. Return safe user information and the token.

Use the same generic error message for unknown email addresses and incorrect passwords to reduce account enumeration.

src/services/auth.service.js
import bcrypt from 'bcryptjs';
import { User } from '../models/user.model.js';
import { AppError } from '../utils/app-error.js';
import { generateAccessToken } from '../utils/jwt.js';

export async function loginUser(email, password) {
  const user = await User.findOne({
    email: email.toLowerCase().trim()
  }).select('+password');

  if (!user) {
    throw new AppError('Invalid email or password', 401);
  }

  const passwordMatches = await bcrypt.compare(
    password,
    user.password
  );

  if (!passwordMatches) {
    throw new AppError('Invalid email or password', 401);
  }

  if (!user.isActive) {
    throw new AppError('Account is disabled', 403);
  }

  const accessToken = generateAccessToken(user);

  return {
    accessToken,
    user: {
      id: user._id,
      name: user.name,
      email: user.email,
      role: user.role
    }
  };
}

Retrieves the user, compares the password hash, checks account status, and returns an access token with safe user information.

Key Takeaways
  • Select the password field explicitly during login.

  • Use bcrypt.compare() to verify passwords.

  • Use generic invalid-credential messages.

  • Return only safe user information.

Validate Registration and Login Requests

Create src/validators/auth.validator.js.

Registration and login should have separate schemas.

Registration requires a name, valid email, and password.

Login requires an email and password.

Never allow clients to provide privileged properties such as role or isActive during registration.

src/validators/auth.validator.js
import { z } from 'zod';

export const registerSchema = z.object({
  name: z.string().trim().min(2).max(80),
  email: z.string().trim().email().max(254),
  password: z.string().min(8).max(72),
  age: z.number().int().min(18).max(120).optional()
}).strict();

export const loginSchema = z.object({
  email: z.string().trim().email().max(254),
  password: z.string().min(1).max(72)
}).strict();

Defines separate validation rules for account registration and login while rejecting unexpected fields.

Key Takeaways
  • Registration and login require different validation rules.

  • Do not allow clients to assign their own roles.

  • Validate credentials before executing authentication logic.

Create Authentication Controllers

Create src/controllers/auth.controller.js.

The controller connects the authentication routes to the authentication service.

Registration should return HTTP 201.

Login should return HTTP 200.

src/controllers/auth.controller.js
import * as authService from '../services/auth.service.js';
import { registerUser } from '../services/user.service.js';

export async function register(req, res, next) {
  try {
    const user = await registerUser(req.body);

    res.status(201).json({
      success: true,
      message: 'Registration successful',
      data: user
    });
  } catch (error) {
    next(error);
  }
}

export async function login(req, res, next) {
  try {
    const result = await authService.loginUser(
      req.body.email,
      req.body.password
    );

    res.status(200).json({
      success: true,
      message: 'Login successful',
      data: result
    });
  } catch (error) {
    next(error);
  }
}

Creates registration and login controllers that delegate authentication operations to service functions.

Key Takeaways
  • Controllers coordinate authentication requests and responses.

  • Registration returns 201.

  • Login returns an access token after successful verification.

Create Authentication Routes

Update src/routes/auth.routes.js.

Register the registration and login endpoints.

Apply validation middleware before the controllers.

src/routes/auth.routes.js
import { Router } from 'express';
import { register, login } from '../controllers/auth.controller.js';
import { validateBody } from '../middleware/validate.middleware.js';
import {
  registerSchema,
  loginSchema
} from '../validators/auth.validator.js';

const router = Router();

router.post('/register', validateBody(registerSchema), register);
router.post('/login', validateBody(loginSchema), login);

export default router;

Registers registration and login endpoints with their respective validation schemas.

Key Takeaways
  • Authentication endpoints belong in a separate router.

  • Validate registration and login payloads.

  • Keep authentication route definitions simple.

Implement JWT Authentication Middleware

Protected routes require a valid access token.

Clients typically send access tokens through the Authorization header:

Authorization: Bearer <access-token>

The authentication middleware should:

  1. Read the Authorization header.
  2. Extract the Bearer token.
  3. Verify the token signature.
  4. Validate issuer, audience, algorithm, and expiration.
  5. Load the corresponding user.
  6. Attach the user to req.user.
  7. Continue to the next middleware.

For this implementation, we will also verify that the user still exists and is active.

Use jwt.verify(), Not jwt.decode()

Decoding a JWT only reads its payload. It does not verify authenticity. Protected APIs must verify the signature and required claims.

src/middleware/auth.middleware.js
import jwt from 'jsonwebtoken';
import { User } from '../models/user.model.js';

export async function authenticate(req, res, next) {
  try {
    const authorization = req.headers.authorization;

    if (!authorization?.startsWith('Bearer ')) {
      return res.status(401).json({
        success: false,
        message: 'Authentication required'
      });
    }

    const token = authorization.slice(7);

    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'rest-api',
      audience: 'rest-api-client'
    });

    if (typeof payload.sub !== 'string') {
      return res.status(401).json({
        success: false,
        message: 'Invalid token'
      });
    }

    const user = await User.findById(payload.sub).select('-password');

    if (!user || !user.isActive) {
      return res.status(401).json({
        success: false,
        message: 'Invalid authentication'
      });
    }

    req.user = user;
    next();
  } catch (error) {
    res.status(401).json({
      success: false,
      message: 'Invalid or expired token'
    });
  }
}

Extracts the Bearer token, verifies its claims, loads the user, and attaches the authenticated user to the request.

Key Takeaways
  • Access tokens are commonly sent through the Authorization header.

  • Verify JWT signatures and claims.

  • Load the authenticated user before protected operations.

  • Never trust decoded token data without verification.

Protect User API Endpoints

Now apply authentication middleware to endpoints that require a logged-in user.

For example:

  • Viewing all users requires an administrator.
  • Viewing a profile requires authentication and ownership or administrator permission.
  • Updating a profile requires authentication and ownership or administrator permission.
  • Deleting a user requires administrator permission.

We will introduce role authorization in the next section.

The create account and login endpoints remain public.

Protect User Routes
import { authenticate } from '../middleware/auth.middleware.js';

router.get('/', authenticate, getAllUsers);
router.get('/:id', authenticate, getUserById);
router.put('/:id', authenticate, updateUser);
router.patch('/:id', authenticate, updateUser);
router.delete('/:id', authenticate, deleteUser);

Adds authentication middleware to the user management endpoints.

Key Takeaways
  • Public and protected endpoints should be separated.

  • Authentication middleware executes before controllers.

  • Authentication alone does not establish resource ownership.

Implement Role-Based Access Control

Role-Based Access Control, commonly called RBAC, restricts operations according to user roles.

Our application currently supports:

  • user
  • admin

Create a reusable middleware that checks whether the authenticated user has one of the permitted roles.

This middleware must execute after authentication because req.user is populated by the authentication middleware.

Role Authorization Middleware
export function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({
        success: false,
        message: 'Authentication required'
      });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        message: 'You do not have permission to perform this action'
      });
    }

    next();
  };
}

Checks the authenticated user's role and rejects requests that do not meet the required permissions.

Key Takeaways
  • RBAC restricts operations according to user roles.

  • Authentication must execute before role authorization.

  • Return 403 when an authenticated user lacks permission.

Implement Resource Ownership Checks

A common security mistake is assuming that any authenticated user can access any resource.

For example, if John is logged in, he should not automatically be allowed to update Sarah's profile.

This is an object-level authorization problem.

The backend must verify whether:

  • The authenticated user owns the requested resource.
  • Or the authenticated user has an administrator role.

For user-specific operations, compare req.user._id with the requested resource ID.

Never rely on frontend visibility controls to enforce authorization.

Prevent Insecure Direct Object References

Always verify resource ownership on the server. Knowing another user's MongoDB ID must never be enough to access or modify their private information.

Ownership Authorization Middleware
import { AppError } from '../utils/app-error.js';

export function authorizeUserOrAdmin(req, res, next) {
  const isOwner = req.user._id.toString() === req.params.id;
  const isAdmin = req.user.role === 'admin';

  if (!isOwner && !isAdmin) {
    return next(new AppError('Access denied', 403));
  }

  next();
}

Allows access when the authenticated user owns the resource or has the administrator role.

Key Takeaways
  • Authentication does not guarantee resource access.

  • Verify resource ownership for user-specific operations.

  • Enforce authorization on every protected endpoint.