DEV Community

137Foundry
137Foundry

Posted on

How to Write Authorization Middleware for Express.js Applications

Authorization in Express is most useful when it's composable: a set of middleware functions that can be applied to any route, that work independently of the route handler, and that fail loudly when permissions are absent rather than silently passing incorrect requests through.

This guide covers a practical approach to building that kind of authorization layer, from basic role checks through more complex attribute-based conditions.

Step 1: Establish What Your Auth Middleware Has to Work With

Authorization middleware runs after authentication middleware. By the time your authorization check runs, req.user should already be populated with the authenticated user's data, including their roles.

If you're using JWTs, the authentication middleware validates the token and attaches the user payload to the request:

const jwt = require('jsonwebtoken');

function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthenticated' });

  try {
    req.user = jwt.verify(token, process.env.JWT_SECRET);
    next();
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }
}
Enter fullscreen mode Exit fullscreen mode

See jwt.io for documentation on token verification. The critical point is that req.user must come from server-side verification, never from a header or body parameter that users can set themselves.

OWASP's authentication guidance reinforces this: role claims in tokens must be verified against a cryptographic signature. An unverified claim is just data a client sent, which has no security value.

Step 2: Build a Basic Role-Based Middleware

With req.user.roles available, a basic role-checking middleware is straightforward:

function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Unauthenticated' });
    }

    const userRoles = Array.isArray(req.user.roles) ? req.user.roles : [];
    const hasPermission = allowedRoles.some(role => userRoles.includes(role));

    if (!hasPermission) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Applied to a route:

const router = require('express').Router();

// Only admins can access this
router.get('/admin/users', authenticate, requireRole('admin'), listUsers);

// Editors and admins can create posts
router.post('/posts', authenticate, requireRole('editor', 'admin'), createPost);

// Any authenticated user can read published posts
router.get('/posts/:id', authenticate, getPost);
Enter fullscreen mode Exit fullscreen mode

This pattern makes the permission requirements visible at the route definition level. Any developer reading the route can see what roles are required without tracing the handler logic.

Step 3: Add Permission-Level Checks

Role-level checks are coarse. Some applications need finer-grained checks: the editor role might have a write:posts permission but not a delete:posts permission.

Define a permissions map and a checking function:

const rolePermissions = {
  admin: ['read', 'write', 'delete', 'manage_users'],
  editor: ['read', 'write'],
  moderator: ['read', 'delete'],
  viewer: ['read']
};

function requirePermission(action) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Unauthenticated' });
    }

    const userPermissions = (req.user.roles || [])
      .flatMap(role => rolePermissions[role] || []);

    if (!userPermissions.includes(action)) {
      return res.status(403).json({ error: 'Forbidden' });
    }

    next();
  };
}

// Usage
router.delete('/posts/:id', authenticate, requirePermission('delete'), deletePost);
Enter fullscreen mode Exit fullscreen mode

This separates the permission model from the role definition. Adding a new role is a change to rolePermissions. Adding a new protected action is a change to one route definition.

The CASL authorization library provides a more powerful version of this pattern for JavaScript applications, including support for condition-based permissions and attribute-based checks. For complex permission models, reaching for a library is often the right choice.

Step 4: Enforce Ownership Conditions

Route-level role checks don't prevent users from accessing other users' resources. A user with the editor role who can access /posts/:id might be able to read or edit posts belonging to other editors if the only check is "are you an editor?"

Handle ownership at the data access layer:

async function getPost(req, res) {
  const { id } = req.params;
  const { user } = req;

  const post = await db('posts').where({ id }).first();

  if (!post) {
    return res.status(404).json({ error: 'Not found' });
  }

  // Admins can see any post; others only see their own drafts
  const canView = 
    user.roles.includes('admin') ||
    post.status === 'published' ||
    post.author_id === user.id;

  if (!canView) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  return res.json(post);
}
Enter fullscreen mode Exit fullscreen mode

For list endpoints, scope the query rather than filtering after the fact:

async function listPosts(req, res) {
  const { user } = req;
  let query = db('posts');

  if (!user.roles.includes('admin')) {
    // Non-admins see published posts plus their own drafts
    query = query.where(function() {
      this.where({ status: 'published' })
        .orWhere({ author_id: user.id });
    });
  }

  const posts = await query;
  return res.json(posts);
}
Enter fullscreen mode Exit fullscreen mode

This approach is more efficient than post-fetch filtering and prevents data from reaching the application layer before being discarded.

Step 5: Centralize and Test the Permission Model

The biggest risk with scattered middleware is inconsistency. A centralized authorization module reduces that risk:

// lib/auth.js
const rolePermissions = {
  admin: ['read', 'write', 'delete', 'manage_users'],
  editor: ['read', 'write'],
  viewer: ['read']
};

function can(user, action, resource) {
  if (!user || !user.roles) return false;

  const permissions = user.roles.flatMap(r => rolePermissions[r] || []);
  if (!permissions.includes(action)) return false;

  // Ownership check for write/delete
  if (['write', 'delete'].includes(action) && resource) {
    return user.roles.includes('admin') || resource.author_id === user.id;
  }

  return true;
}

module.exports = { can, rolePermissions };
Enter fullscreen mode Exit fullscreen mode

With a centralized can() function, you can write tests that verify the permission model independently:

test('editors can write but not delete others posts', () => {
  const editor = { roles: ['editor'], id: 1 };
  const otherPost = { author_id: 2 };

  expect(can(editor, 'write', otherPost)).toBe(false);
  expect(can(editor, 'delete', otherPost)).toBe(false);
});
Enter fullscreen mode Exit fullscreen mode

Casbin provides an external policy engine if your permission model grows complex enough that a centralized JS function becomes hard to maintain. Open Policy Agent serves the same purpose for multi-service architectures. Node.js and Express.js documentation cover the middleware pattern in detail.

Step 6: Handle Multi-Tenant Isolation

Applications with multiple organizations or teams face an additional authorization boundary: not just "does this user have the right role?" but "does this user belong to the organization that owns this resource?"

Add a tenant check at the middleware level or in the data access layer:

async function requireSameTenant(req, res, next) {
  const resource = await db('resources').where({ id: req.params.id }).first();
  if (!resource) return res.status(404).json({ error: 'Not found' });
  if (resource.org_id !== req.user.org_id) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  req.resource = resource;
  next();
}
Enter fullscreen mode Exit fullscreen mode

Tenant isolation is a data-layer concern, not just a role concern. Cross-tenant data leakage is one of the most serious authorization failures in multi-tenant SaaS applications and consistently appears in OWASP's broken access control examples.

What to Avoid

A few patterns consistently cause authorization problems.

Never trust role or permission data from request headers, body, or query parameters. Roles must be resolved from the verified user identity, not from what the client claims. This is fundamental and worth repeating because it's a consistently exploited failure mode.

Don't assume that hidden UI elements constitute access control. Route handlers need their own checks regardless of what the frontend does.

Don't skip data-layer scoping in favor of filtering results after the fact. Query-level scoping is more efficient, less error-prone, and prevents sensitive data from reaching the application layer unnecessarily.

For a deeper look at the full RBAC implementation, including the permission model design approach, the detailed guide How to Implement Role-Based Access Control in Web Applications covers both the design phase and the route-plus-data-layer enforcement pattern in detail.

137Foundry web development services includes authorization architecture and implementation for web applications where getting this layer right from the start matters.

Terminal showing Express.js application code for authorization middleware
Photo by Braeson Holland on Pexels

Developer at workstation reviewing security implementation in web application
Photo by Pexels on Pixabay

Top comments (0)