API Authorization Patterns: RBAC, ABAC and Policy-as-Code
Robust API authorization with RBAC, ABAC and policy-as-code — protecting resources at every layer while staying maintainable as the system grows.

Authentication answers "who are you?" Authorization answers "what can you do?" Most APIs nail authentication with JWT or OAuth but implement authorization as scattered if statements throughout the codebase. When a new role needs access to an existing endpoint, developers play whack-a-mole across dozens of files. Centralizing authorization logic into a coherent policy engine prevents this decay.
The Problem with Inline Authorization
Authorization logic tends to start simple and grow into an unmaintainable mess.
// ❌ Authorization scattered across handlers
async function deleteProject(req: Request, res: Response) {
const user = req.user;
const project = await db.projects.findById(req.params.id);
// First check: is this their project?
if (project.ownerId !== user.id) {
// Wait, admins can delete anything
if (user.role !== "admin") {
// Actually, org admins can delete within their org
if (
user.role !== "org_admin" ||
project.orgId !== user.orgId
) {
// Team leads can delete in their team's projects
if (
user.role !== "team_lead" ||
!user.teamIds.includes(project.teamId)
) {
return res.status(403).json({ error: "Forbidden" });
}
}
}
}
// Duplicate logic exists in updateProject, archiveProject,
// transferProject, and every new endpoint touching projects
await db.projects.delete(project.id);
return res.json({ success: true });
}// ✅ Centralized authorization with clear policy
async function deleteProject(req: Request, res: Response) {
const project = await db.projects.findById(req.params.id);
const allowed = await authorize({
subject: req.user,
action: "delete",
resource: project,
resourceType: "project",
});
if (!allowed) {
return res.status(403).json({ error: "Forbidden" });
}
await db.projects.delete(project.id);
return res.json({ success: true });
}The centralized version is readable, testable, and maintainable. The policy definition lives in one place, and every handler delegates to it.
Role-Based Access Control (RBAC)
RBAC assigns permissions to roles, then assigns roles to users. It works well for systems with clearly defined user categories but struggles with contextual permissions.
interface Role {
name: string;
permissions: Permission[];
}
interface Permission {
resource: string;
actions: string[];
}
const roles: Role[] = [
{
name: "viewer",
permissions: [
{ resource: "project", actions: ["read", "list"] },
{ resource: "document", actions: ["read", "list"] },
],
},
{
name: "editor",
permissions: [
{ resource: "project", actions: ["read", "list", "update"] },
{ resource: "document", actions: ["read", "list", "create", "update"] },
],
},
{
name: "admin",
permissions: [
{ resource: "project", actions: ["read", "list", "create", "update", "delete"] },
{ resource: "document", actions: ["read", "list", "create", "update", "delete"] },
{ resource: "user", actions: ["read", "list", "create", "update", "delete"] },
],
},
];
class RBACEngine {
private roleMap: Map<string, Role>;
constructor(roles: Role[]) {
this.roleMap = new Map(roles.map(r => [r.name, r]));
}
check(
userRoles: string[],
resource: string,
action: string
): boolean {
for (const roleName of userRoles) {
const role = this.roleMap.get(roleName);
if (!role) continue;
const permission = role.permissions.find(
p => p.resource === resource
);
if (permission && permission.actions.includes(action)) {
return true;
}
}
return false;
}
}
const rbac = new RBACEngine(roles);
rbac.check(["editor"], "document", "create"); // true
rbac.check(["viewer"], "document", "delete"); // falseRBAC is straightforward but hits its ceiling when permissions depend on resource ownership, team membership, or other contextual attributes.
Attribute-Based Access Control (ABAC)
ABAC evaluates policies against attributes of the subject, resource, action, and environment. This handles contextual permissions that RBAC cannot express.
interface PolicyRule {
id: string;
description: string;
effect: "allow" | "deny";
condition: (ctx: AuthorizationContext) => boolean;
}
interface AuthorizationContext {
subject: {
id: string;
roles: string[];
orgId: string;
teamIds: string[];
department: string;
};
action: string;
resource: {
type: string;
ownerId: string;
orgId: string;
teamId: string;
sensitivity: "public" | "internal" | "confidential";
status: string;
};
environment: {
ipAddress: string;
time: Date;
mfaVerified: boolean;
};
}
const policies: PolicyRule[] = [
{
id: "owner-full-access",
description: "Resource owners can do anything with their resources",
effect: "allow",
condition: (ctx) =>
ctx.subject.id === ctx.resource.ownerId,
},
{
id: "team-member-read-write",
description: "Team members can read and update team resources",
effect: "allow",
condition: (ctx) =>
ctx.subject.teamIds.includes(ctx.resource.teamId) &&
["read", "update", "list"].includes(ctx.action),
},
{
id: "confidential-requires-mfa",
description: "Confidential resources require MFA verification",
effect: "deny",
condition: (ctx) =>
ctx.resource.sensitivity === "confidential" &&
!ctx.environment.mfaVerified,
},
{
id: "no-delete-archived",
description: "Cannot delete archived resources",
effect: "deny",
condition: (ctx) =>
ctx.action === "delete" &&
ctx.resource.status === "archived",
},
];
function evaluatePolicies(
policies: PolicyRule[],
ctx: AuthorizationContext
): boolean {
// Deny rules take priority
for (const policy of policies) {
if (policy.effect === "deny" && policy.condition(ctx)) {
return false;
}
}
// Check for at least one allow
for (const policy of policies) {
if (policy.effect === "allow" && policy.condition(ctx)) {
return true;
}
}
// Default deny
return false;
}The deny-overrides evaluation strategy means protective rules always win, regardless of ordering. This prevents accidental access grants when new allow policies are added.
Middleware-Based Enforcement
Authorization should be enforced at the middleware layer so individual handlers can't accidentally skip permission checks.
interface ResourceResolver {
resolve: (req: Request) => Promise<AuthorizableResource>;
}
interface AuthorizableResource {
type: string;
ownerId: string;
orgId: string;
teamId: string;
sensitivity: string;
status: string;
}
function requirePermission(
action: string,
resourceResolver: ResourceResolver
) {
return async (
req: Request,
res: Response,
next: NextFunction
) => {
const resource = await resourceResolver.resolve(req);
const ctx: AuthorizationContext = {
subject: {
id: req.user.id,
roles: req.user.roles,
orgId: req.user.orgId,
teamIds: req.user.teamIds,
department: req.user.department,
},
action,
resource: {
type: resource.type,
ownerId: resource.ownerId,
orgId: resource.orgId,
teamId: resource.teamId,
sensitivity: resource.sensitivity,
status: resource.status,
},
environment: {
ipAddress: req.ip ?? "unknown",
time: new Date(),
mfaVerified: req.user.mfaVerified ?? false,
},
};
const allowed = evaluatePolicies(policies, ctx);
if (!allowed) {
return res.status(403).json({
error: "Insufficient permissions",
action,
resource: resource.type,
});
}
next();
};
}
// Usage in routes
const projectResolver: ResourceResolver = {
resolve: async (req) => {
const project = await db.projects.findById(req.params.id);
return {
type: "project",
ownerId: project.ownerId,
orgId: project.orgId,
teamId: project.teamId,
sensitivity: project.sensitivity,
status: project.status,
};
},
};
router.delete(
"/projects/:id",
requirePermission("delete", projectResolver),
deleteProjectHandler
);Testing Authorization Policies
Authorization logic is critical enough to deserve comprehensive tests. Policy tests should cover every role-resource-action combination and edge cases.
describe("Authorization Policies", () => {
const baseCtx: AuthorizationContext = {
subject: {
id: "user-1",
roles: ["editor"],
orgId: "org-1",
teamIds: ["team-1"],
department: "engineering",
},
action: "read",
resource: {
type: "project",
ownerId: "user-2",
orgId: "org-1",
teamId: "team-1",
sensitivity: "internal",
status: "active",
},
environment: {
ipAddress: "10.0.0.1",
time: new Date("2023-04-26T10:00:00Z"),
mfaVerified: true,
},
};
test("team members can read team resources", () => {
expect(evaluatePolicies(policies, baseCtx)).toBe(true);
});
test("confidential resources denied without MFA", () => {
const ctx = {
...baseCtx,
resource: { ...baseCtx.resource, sensitivity: "confidential" },
environment: { ...baseCtx.environment, mfaVerified: false },
};
expect(evaluatePolicies(policies, ctx)).toBe(false);
});
test("cannot delete archived resources even as owner", () => {
const ctx = {
...baseCtx,
action: "delete",
subject: { ...baseCtx.subject, id: "user-2" }, // is owner
resource: { ...baseCtx.resource, ownerId: "user-2", status: "archived" },
};
expect(evaluatePolicies(policies, ctx)).toBe(false);
});
});Key Takeaways
Authorization is too important to scatter across handler functions and too complex to model with simple role checks alone. Start with RBAC for clear-cut role boundaries, then layer ABAC policies for contextual rules like ownership, team membership, sensitivity levels, and MFA requirements. Use deny-overrides evaluation so protective rules always take precedence. Enforce authorization at the middleware layer so handlers cannot accidentally skip checks. Test policies as thoroughly as you test business logic—every role-action-resource combination should have a corresponding test case. The goal is a system where adding a new permission rule means editing one policy file, not hunting through dozens of route handlers.


