DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Implement RBAC + ABAC Authorization in Node.js APIs (2026 Guide)

Building a production API without proper authorization is like locking your front door but leaving the windows open. Authentication answers who are you? — authorization answers what can you do?

Most Node.js tutorials stop at JWT verification. That's authentication. Real security requires a layered authorization model, and in 2026 the industry consensus is clear: combine RBAC (Role-Based Access Control) with ABAC (Attribute-Based Access Control) to cover both coarse-grained and fine-grained access control.

This guide walks you through implementing both patterns from scratch — no heavy external dependencies, production-ready middleware, and patterns used in real SaaS APIs.


What's the Difference? RBAC vs ABAC

RBAC assigns permissions to roles, and roles to users. Simple, fast, easy to reason about:

user → roles[] → permissions[]
Enter fullscreen mode Exit fullscreen mode

Example: A moderator can delete:comment but not delete:user. An admin can do both.

ABAC grants access based on arbitrary attributes of the subject (user), resource, and environment:

allow IF user.department == resource.department AND time.hour < 18
Enter fullscreen mode Exit fullscreen mode

Example: A doctor can only read patients in their own department, and only during business hours.

The catch: RBAC breaks down when you need resource-level permissions ("Alice can edit her own post, but no one else's"). ABAC breaks down when you have thousands of policies — it becomes a policy management nightmare.

The solution used in production: Use RBAC as your coarse filter (fast, cached), then ABAC as your fine-grained check (dynamic, contextual).


Project Setup

mkdir api-auth-patterns && cd api-auth-patterns
npm init -y
npm install express jsonwebtoken zod
npm install -D @types/node typescript ts-node
Enter fullscreen mode Exit fullscreen mode

We'll use Express for the examples, but the patterns apply to Hono, Fastify, ElysiaJS, or any Node.js framework.


Step 1: Define Your Permission Schema

Start by modeling permissions as strings: resource:action. This is the most portable format — it works in JWTs, databases, and policy engines.

// src/types/auth.ts
export type Permission =
  | 'posts:read'
  | 'posts:create'
  | 'posts:update'
  | 'posts:delete'
  | 'users:read'
  | 'users:update'
  | 'users:delete'
  | 'analytics:read'
  | 'billing:read'
  | 'billing:update';

export type Role = 'viewer' | 'editor' | 'moderator' | 'admin';

export interface AuthUser {
  id: string;
  email: string;
  roles: Role[];
  organizationId: string;
  department?: string;
  attributes?: Record<string, unknown>;
}

export interface JWTPayload extends AuthUser {
  iat: number;
  exp: number;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: The Role-Permission Matrix (RBAC Core)

// src/auth/rbac.ts
import { Permission, Role } from '../types/auth';

// Role → Permissions mapping. In production, load this from a DB or config service.
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
  viewer: [
    'posts:read',
    'analytics:read',
  ],
  editor: [
    'posts:read',
    'posts:create',
    'posts:update',
    'analytics:read',
  ],
  moderator: [
    'posts:read',
    'posts:create',
    'posts:update',
    'posts:delete',
    'users:read',
    'analytics:read',
  ],
  admin: [
    'posts:read',
    'posts:create',
    'posts:update',
    'posts:delete',
    'users:read',
    'users:update',
    'users:delete',
    'analytics:read',
    'billing:read',
    'billing:update',
  ],
};

// Cache expanded permissions per role combo (avoids recomputing on every request)
const permissionCache = new Map<string, Set<Permission>>();

export function getPermissionsForRoles(roles: Role[]): Set<Permission> {
  const cacheKey = [...roles].sort().join(',');

  if (permissionCache.has(cacheKey)) {
    return permissionCache.get(cacheKey)!;
  }

  const permissions = new Set<Permission>();
  for (const role of roles) {
    const rolePerms = ROLE_PERMISSIONS[role] ?? [];
    for (const perm of rolePerms) {
      permissions.add(perm);
    }
  }

  permissionCache.set(cacheKey, permissions);
  return permissions;
}

export function hasPermission(roles: Role[], permission: Permission): boolean {
  return getPermissionsForRoles(roles).has(permission);
}
Enter fullscreen mode Exit fullscreen mode

Step 3: RBAC Middleware

// src/middleware/rbac.ts
import { Request, Response, NextFunction } from 'express';
import { Permission } from '../types/auth';
import { hasPermission } from '../auth/rbac';

// Extend Express Request to include the authenticated user
declare global {
  namespace Express {
    interface Request {
      user?: import('../types/auth').AuthUser;
    }
  }
}

/**
 * Middleware factory: requires the authenticated user to have ALL listed permissions.
 * Usage: router.get('/posts', requirePermissions('posts:read'), handler)
 */
export function requirePermissions(...permissions: Permission[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }

    const missingPermissions = permissions.filter(
      (perm) => !hasPermission(req.user!.roles, perm)
    );

    if (missingPermissions.length > 0) {
      res.status(403).json({
        error: 'Insufficient permissions',
        required: permissions,
        missing: missingPermissions,
      });
      return;
    }

    next();
  };
}

/**
 * Middleware factory: requires the user to have at least ONE of the listed permissions.
 * Usage: requireAnyPermission('posts:update', 'posts:delete')
 */
export function requireAnyPermission(...permissions: Permission[]) {
  return (req: Request, res: Response, next: NextFunction): void => {
    if (!req.user) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }

    const hasAny = permissions.some((perm) =>
      hasPermission(req.user!.roles, perm)
    );

    if (!hasAny) {
      res.status(403).json({
        error: 'Insufficient permissions',
        required: `one of: ${permissions.join(', ')}`,
      });
      return;
    }

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

Step 4: ABAC Policy Engine

ABAC policies are functions that take (user, resource, context) and return allow | deny. Keep them pure — no side effects, easy to test.

// src/auth/abac.ts
import { AuthUser } from '../types/auth';

export interface PolicyContext {
  action: string;
  resource?: Record<string, unknown>;
  environment?: {
    ip?: string;
    userAgent?: string;
    timestamp?: Date;
  };
}

export type PolicyResult = 'allow' | 'deny' | 'abstain';

export type PolicyFunction = (
  user: AuthUser,
  context: PolicyContext
) => PolicyResult;

/**
 * Policy: users can only modify their own resources
 */
const ownershipPolicy: PolicyFunction = (user, context) => {
  if (!context.resource) return 'abstain';

  const isOwner =
    context.resource.authorId === user.id ||
    context.resource.userId === user.id ||
    context.resource.ownerId === user.id;

  if (['update', 'delete'].includes(context.action)) {
    return isOwner ? 'allow' : 'deny';
  }

  return 'abstain';
};

/**
 * Policy: same-organization isolation (multi-tenant)
 */
const organizationPolicy: PolicyFunction = (user, context) => {
  if (!context.resource) return 'abstain';

  if (context.resource.organizationId !== undefined) {
    return context.resource.organizationId === user.organizationId
      ? 'allow'
      : 'deny';
  }

  return 'abstain';
};

/**
 * Policy: admins bypass all restrictions
 */
const adminBypassPolicy: PolicyFunction = (user, _context) => {
  return user.roles.includes('admin') ? 'allow' : 'abstain';
};

/**
 * Policy: rate-limit sensitive actions outside business hours (UTC+7)
 */
const businessHoursPolicy: PolicyFunction = (user, context) => {
  if (context.action !== 'delete') return 'abstain';

  const now = context.environment?.timestamp ?? new Date();
  // Convert to Asia/Bangkok (UTC+7)
  const hour = (now.getUTCHours() + 7) % 24;

  // Destructive actions outside 08:00-20:00 require admin
  if (hour < 8 || hour >= 20) {
    return user.roles.includes('admin') ? 'allow' : 'deny';
  }

  return 'abstain';
};

// Policy registry - evaluated in order (first non-abstain wins, deny beats allow)
const policies: PolicyFunction[] = [
  adminBypassPolicy,     // Admins always win
  businessHoursPolicy,   // Time-based restrictions
  organizationPolicy,    // Org isolation (multi-tenant)
  ownershipPolicy,       // Resource ownership
];

/**
 * Evaluate all policies: returns true if access should be allowed.
 * Strategy: any 'deny' blocks access. 'allow' required from at least one policy.
 * If all policies abstain, access is denied by default (secure-by-default).
 */
export function evaluatePolicies(
  user: AuthUser,
  context: PolicyContext
): boolean {
  let hasAllow = false;

  for (const policy of policies) {
    const result = policy(user, context);

    if (result === 'deny') return false;   // Hard stop
    if (result === 'allow') hasAllow = true;
  }

  return hasAllow; // Deny if nothing explicitly allowed
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Combined RBAC + ABAC Middleware

The key insight: RBAC gates whether you can perform an action at all, ABAC gates whether you can perform it on a specific resource.

// src/middleware/authorize.ts
import { Request, Response, NextFunction } from 'express';
import { Permission } from '../types/auth';
import { hasPermission } from '../auth/rbac';
import { evaluatePolicies, PolicyContext } from '../auth/abac';

interface AuthorizeOptions {
  permission: Permission;
  /** Callback to load the resource being accessed */
  getResource?: (req: Request) => Promise<Record<string, unknown> | null>;
  /** The action for ABAC evaluation (defaults to the HTTP method) */
  action?: string;
}

export function authorize(options: AuthorizeOptions) {
  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    const { permission, getResource, action } = options;

    // 1. Authentication check
    if (!req.user) {
      res.status(401).json({ error: 'Authentication required' });
      return;
    }

    // 2. RBAC check (fast, in-memory, cached)
    if (!hasPermission(req.user.roles, permission)) {
      res.status(403).json({
        error: 'Forbidden',
        detail: `Missing permission: ${permission}`,
      });
      return;
    }

    // 3. ABAC check (only when a resource loader is provided)
    if (getResource) {
      const resource = await getResource(req);

      if (!resource) {
        res.status(404).json({ error: 'Resource not found' });
        return;
      }

      // Attach resource to request for the route handler
      (req as any).resource = resource;

      const abacContext: PolicyContext = {
        action: action ?? req.method.toLowerCase(),
        resource,
        environment: {
          ip: req.ip,
          userAgent: req.headers['user-agent'],
          timestamp: new Date(),
        },
      };

      const allowed = evaluatePolicies(req.user, abacContext);

      if (!allowed) {
        res.status(403).json({
          error: 'Forbidden',
          detail: 'Access denied by authorization policy',
        });
        return;
      }
    }

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

Step 6: Wire It All Together

// src/routes/posts.ts
import { Router } from 'express';
import { authorize } from '../middleware/authorize';
import { requirePermissions } from '../middleware/rbac';
import { db } from '../db'; // your database layer

const router = Router();

// List posts - RBAC only (no resource to load)
router.get(
  '/',
  requirePermissions('posts:read'),
  async (req, res) => {
    const posts = await db.posts.findByOrg(req.user!.organizationId);
    res.json(posts);
  }
);

// Get single post - RBAC + ABAC (org isolation check)
router.get(
  '/:id',
  authorize({
    permission: 'posts:read',
    getResource: async (req) => db.posts.findById(req.params.id),
    action: 'read',
  }),
  (req, res) => {
    res.json((req as any).resource);
  }
);

// Update post - RBAC + ABAC (must own the post or be admin)
router.put(
  '/:id',
  authorize({
    permission: 'posts:update',
    getResource: async (req) => db.posts.findById(req.params.id),
    action: 'update',
  }),
  async (req, res) => {
    const updated = await db.posts.update(req.params.id, req.body);
    res.json(updated);
  }
);

// Delete post - RBAC + ABAC + time restriction
router.delete(
  '/:id',
  authorize({
    permission: 'posts:delete',
    getResource: async (req) => db.posts.findById(req.params.id),
    action: 'delete',
  }),
  async (req, res) => {
    await db.posts.delete(req.params.id);
    res.status(204).send();
  }
);

export default router;
Enter fullscreen mode Exit fullscreen mode

Step 7: JWT Authentication Middleware (Bonus)

// src/middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { JWTPayload } from '../types/auth';

const JWT_SECRET = process.env.JWT_SECRET!;

export function authenticate(req: Request, res: Response, next: NextFunction): void {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    res.status(401).json({ error: 'Missing or malformed Authorization header' });
    return;
  }

  const token = authHeader.slice(7);

  try {
    const payload = jwt.verify(token, JWT_SECRET) as JWTPayload;

    req.user = {
      id: payload.id,
      email: payload.email,
      roles: payload.roles,
      organizationId: payload.organizationId,
      department: payload.department,
      attributes: payload.attributes,
    };

    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Authorization Layer

Unit test your policies in isolation — they're pure functions, so this is easy:

// src/auth/abac.test.ts
import { evaluatePolicies } from './abac';
import { AuthUser } from '../types/auth';

const viewer: AuthUser = {
  id: 'user-1',
  email: 'alice@example.com',
  roles: ['viewer'],
  organizationId: 'org-1',
};

const admin: AuthUser = {
  id: 'admin-1',
  email: 'admin@example.com',
  roles: ['admin'],
  organizationId: 'org-1',
};

describe('ownershipPolicy', () => {
  it('allows a user to update their own resource', () => {
    const result = evaluatePolicies(viewer, {
      action: 'update',
      resource: { id: 'post-1', authorId: 'user-1', organizationId: 'org-1' },
    });
    expect(result).toBe(true);
  });

  it("denies a user from updating someone else's resource", () => {
    const result = evaluatePolicies(viewer, {
      action: 'update',
      resource: { id: 'post-2', authorId: 'user-999', organizationId: 'org-1' },
    });
    expect(result).toBe(false);
  });

  it('allows admin to update any resource (bypass)', () => {
    const result = evaluatePolicies(admin, {
      action: 'update',
      resource: { id: 'post-2', authorId: 'user-999', organizationId: 'org-99' },
    });
    expect(result).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Cache permission sets aggressively. Role-to-permission mapping is static. The permissionCache in Step 2 ensures you never recompute the same set twice.

Keep ABAC policies synchronous where possible. If a policy needs a database hit, batch-load the context before calling evaluatePolicies. Don't make DB calls inside each policy function.

Log authorization decisions. In production, log every deny with userId, permission, resourceId, and reason — this is your audit trail:

if (!allowed) {
  logger.warn('authorization_denied', {
    userId: req.user.id,
    permission,
    resourceId: resource.id,
    action,
    ip: req.ip,
  });
}
Enter fullscreen mode Exit fullscreen mode

Summary

Layer What it does When to use
RBAC Coarse permission check Always — fast, cached, simple
ABAC (ownership) Resource-level access User-generated content, profiles
ABAC (org isolation) Multi-tenant isolation SaaS, multi-org APIs
ABAC (time/env) Contextual restrictions Compliance, sensitive actions

The combination gives you security-in-depth: even if an attacker escalates their role, ABAC policies block cross-tenant and cross-user access. Even if an ABAC policy has a bug, RBAC serves as a hard outer gate.

Start with RBAC only. Add ABAC policies as you encounter access control requirements that roles can't express cleanly. That's how most production APIs evolve — and it keeps your code maintainable.

For complex multi-tenant SaaS with hundreds of permission rules, consider dedicated policy engines like Casbin, Open Policy Agent (OPA), or Permify.


Need to monetize your API? Check out 1xAPI on RapidAPI for ready-to-use API services.

Top comments (0)