Building Secure APIs in Node.js: Setting Up Protected Routes with Express

Learn how to secure your Node.js API with Express by setting up protected routes, enforcing role-based access, and using HTTP-only cookies to store JWTs. This guide covers middleware for authentication and authorization, ensuring only trusted users access sensitive routes.

Building Secure APIs in Node.js: Setting Up Protected Routes with Express

Creating a secure API is crucial when building modern web applications, especially when handling sensitive user information. In this blog post, we’ll walk through setting up protected routes in Node.js and Express API using middleware for authentication and authorization. We’ll employ HTTP-only cookies to securely store JWTs (JSON Web Tokens), check cookie validity with middleware, and enforce role-based access control (RBAC).

For readers interested in learning why HTTP-only cookies are a secure choice for storing JWTs, check out my previous blog (link here).

Best Practices for Securing JWT Tokens: A Comprehensive Guide
JWTs enable stateless authentication, but securing them is critical. Avoid storing tokens in local storage due to XSS risks. Instead, use HttpOnly, Secure cookies to prevent access via JavaScript and limit CSRF attacks. Pair short-lived access tokens with refresh tokens for enhanced security.

Why Use Protected Routes?

Protected routes help secure parts of your application by ensuring that only authenticated and, in some cases, authorized users can access them. This is particularly valuable for APIs, as it helps prevent unauthorized access to sensitive data. With Express, we can easily set up middleware functions to authenticate users and restrict access to specific routes.

Setting Up the Authorize Middleware

The first middleware we’ll implement is authorize, which verifies if a user is authenticated. This middleware will:

  • Check for an access token (JWT) in the HTTP-only cookie for web clients.
  • Verify the token using jwt.verify.
  • Ensure that the device making the request is trusted by cross-referencing the device information stored in the database.

Here’s the authorize middleware:

import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { ExtendedRequest } from "../domains/user/types";

export const authorize = (req: Request, res: Response, next: NextFunction) => {
  try {
    let accessToken = req.cookies?.accessToken;

    if (!accessToken) {
      return res.status(401).json({ error: "Access token not provided" });
    }

    // Verify JWT
    jwt.verify(
      accessToken,
      process.env.SECRET_KEY || "",
      async (err: any, decoded: any) => {
        if (err) {
          return res.status(403).json({ error: "Invalid access token" });
        }

        // Attach decoded user data to request
        (req as ExtendedRequest).user = decoded;
        next();
      }
    );
  } catch (error) {
    console.error("Error in authorize middleware:", error);
    res.status(500).json({ error: "Internal server error" });
  }
};

Key Points of the authorize Middleware

  • Token Retrieval: For web clients, we retrieve the JWT from an HTTP-only cookie;
  • JWT Verification: Using thisjwt.verify, we check if the token is valid and hasn’t expired.

Enforcing Role-Based Access Control (RBAC)

For certain API endpoints, only specific users (like admins) should be allowed access. We can implement this with another middleware function, allowAdminsOnly, which checks if the user has the correct role to access the route.

Here’s the allowAdminsOnly middleware:

import { Request, Response, NextFunction } from "express";
import { ExtendedRequest } from "../domains/user/types";
import { userTypes } from "../utils/constants";

export const allowAdminsOnly = (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    const user = (req as ExtendedRequest).user;
    if (!user) {
      return res.status(401).json({ error: "Unauthorized. Please log in." });
    }

    // Check if user role is ADMIN
    if (user.user_role === userTypes.ADMIN) {
      return next();
    }

    return res.status(403).json({ error: "You are not an admin" });
  } catch (error) {
    console.error("Error in checking admin role:", error);
    res.status(500).json({ error: "Internal server error" });
  }
};

Key Points of the allowAdminsOnly Middleware

  • User Verification: We retrieve the user data from req.user (added by the authorize middleware) and check if the user_role is ADMIN.
  • Access Restriction: If the user does not have an admin role, a 403 Forbidden response is sent, indicating insufficient permissions.

Using Middleware in Routes

To protect routes, we can chain the authorize and allowAdminsOnly middlewares in specific endpoints. Here’s an example:

import express from "express";
import { authorize } from "./middlewares/authorize";
import { allowAdminsOnly } from "./middlewares/allowAdminsOnly";

const router = express.Router();

// Public route
router.get("/public", (req, res) => {
  res.json({ message: "This is a public route, accessible by anyone." });
});

// Protected route, only accessible by authenticated users
router.get("/profile", authorize, (req, res) => {
  res.json({ message: "This is a protected route, accessible by authenticated users." });
});

// Admin-only route, accessible by admins only
router.get("/admin", authorize, allowAdminsOnly, (req, res) => {
  res.json({ message: "Welcome Admin! This is a secure, admin-only route." });
});

export default router;

How It Works

  1. Public Route (/public): Anyone can access this route, and it doesn’t require any authentication.
  2. Protected Route (/profile): This route requires a valid access token and a trusted device. Only authenticated users can access it.
  3. Admin-Only Route (/admin): This route is restricted to users with an admin role, using the allowAdminsOnly middleware.

Error Handling and Best Practices

  1. Clear Error Messages: Always provide meaningful error messages (e.g., "Access token not provided" or "Invalid or inactive device") to inform users why their request is being denied.
  2. Token Expiration: Regularly expire and refresh tokens to enhance security.
  3. Logging: Use server-side logging to capture and analyze unauthorized access attempts or errors in middleware.
  4. Environment Variables: Use process.env to manage secrets securely. Never hard-code sensitive information SECRET_KEY directly in your code.

By setting up protected routes using the authorize and allowAdminsOnly middleware functions, you create a secure API that supports both authentication and role-based access control in your Express application. This approach enhances security, ensures only trusted devices have access and restricts sensitive areas of the application to authorized users.

You can check out the NodeJS page, where you will find lots of useful articles for your next project.

Getting to Know NodeJS: Core Features and Use Cases
Node.js is a powerful, open-source, server-side platform built on Chrome’s V8 JavaScript engine. Known for its speed, efficiency, and scalability, it has become the go-to choice for building fast and scalable network applications. Whether you’re just starting out or looking to enhance your skills, this hub serves as your

That's it for today. See ya 👋