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[]
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
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
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;
}
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);
}
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();
};
}
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
}
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();
};
}
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;
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' });
}
}
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);
});
});
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,
});
}
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)