Access Control (AuthZ)
Authorization answers the question "what can you do?" After authentication verifies identity, authorization determines permissions. This article covers RBAC, ABAC, and practical implementation patterns for enforcing access control in your API.
AuthN vs AuthZ
These terms are often confused:
| Aspect | Authentication (AuthN) | Authorization (AuthZ) |
|---|---|---|
| Question | "Who are you?" | "What can you do?" |
| Timing | First | After AuthN |
| Failure | 401 Unauthorized | 403 Forbidden |
| Example | Verify JWT signature | Check if user can delete post |
// Authentication middleware - verifies identity
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: { code: 'UNAUTHORIZED' } });
}
try {
req.user = verifyToken(token);
next();
} catch {
return res.status(401).json({ error: { code: 'INVALID_TOKEN' } });
}
};
// Authorization middleware - verifies permissions
const authorize = (permission: string) => {
return (req, res, next) => {
if (!req.user.permissions.includes(permission)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: `Missing required permission: ${permission}`,
},
});
}
next();
};
};
// Usage
router.delete(
'/posts/:id',
authenticate, // First: verify identity
authorize('posts:delete'), // Then: verify permission
deletePostHandler
);
RBAC (Role-Based Access Control)
RBAC assigns permissions to roles, and roles to users. It's the most common authorization model—simple to understand and implement.
Basic RBAC Model
User → Role → Permission
Example:
Alice → Admin → [create, read, update, delete]
Bob → Editor → [create, read, update]
Carol → Viewer → [read]
Implementation
// src/types/auth.ts
type Permission =
| 'users:read'
| 'users:create'
| 'users:update'
| 'users:delete'
| 'posts:read'
| 'posts:create'
| 'posts:update'
| 'posts:delete'
| 'posts:publish'
| 'analytics:read'
| 'settings:manage';
type Role = 'admin' | 'editor' | 'author' | 'viewer';
// Define role → permissions mapping
const rolePermissions: Record<Role, Permission[]> = {
admin: [
'users:read', 'users:create', 'users:update', 'users:delete',
'posts:read', 'posts:create', 'posts:update', 'posts:delete', 'posts:publish',
'analytics:read', 'settings:manage',
],
editor: [
'users:read',
'posts:read', 'posts:create', 'posts:update', 'posts:publish',
'analytics:read',
],
author: [
'posts:read', 'posts:create', 'posts:update',
],
viewer: [
'posts:read',
],
};
// src/lib/authorization.ts
export const hasPermission = (
user: { role: Role },
permission: Permission
): boolean => {
const permissions = rolePermissions[user.role] || [];
return permissions.includes(permission);
};
export const hasAnyPermission = (
user: { role: Role },
permissions: Permission[]
): boolean => {
return permissions.some(p => hasPermission(user, p));
};
export const hasAllPermissions = (
user: { role: Role },
permissions: Permission[]
): boolean => {
return permissions.every(p => hasPermission(user, p));
};
// src/middleware/authorize.ts
export const requirePermission = (permission: Permission) => {
return (req, res, next) => {
if (!hasPermission(req.user, permission)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action',
},
});
}
next();
};
};
export const requireAnyPermission = (...permissions: Permission[]) => {
return (req, res, next) => {
if (!hasAnyPermission(req.user, permissions)) {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You do not have permission to perform this action',
},
});
}
next();
};
};
// Usage in routes
router.get('/users', authenticate, requirePermission('users:read'), listUsers);
router.post('/users', authenticate, requirePermission('users:create'), createUser);
router.delete('/users/:id', authenticate, requirePermission('users:delete'), deleteUser);
router.put(
'/posts/:id/publish',
authenticate,
requireAnyPermission('posts:publish', 'posts:update'),
publishPost
);
Database Schema for RBAC
-- Simple RBAC: role on user table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255),
role VARCHAR(50) NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP DEFAULT NOW()
);
-- Complex RBAC: many-to-many with multiple roles
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(50) UNIQUE NOT NULL,
description TEXT
);
CREATE TABLE permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL, -- e.g., 'posts:create'
description TEXT
);
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role_id UUID REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
// Fetch user with all permissions (complex RBAC)
const getUserWithPermissions = async (userId: string) => {
const result = await db.query(`
SELECT DISTINCT p.name as permission
FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = $1
`, [userId]);
return result.rows.map(r => r.permission);
};
RBAC Limitations
RBAC works well for coarse-grained access ("can this user edit any post?") but struggles with fine-grained control ("can this user edit this specific post?"). For resource-level permissions, you need ABAC.
ABAC (Attribute-Based Access Control)
ABAC makes decisions based on attributes of the user, resource, action, and context. It enables policies like "users can edit posts they own" or "admins can approve orders over $1000 during business hours."
ABAC Components
- Subject: The user or system making the request (attributes: role, department, clearance)
- Resource: What's being accessed (attributes: owner, status, sensitivity)
- Action: What operation (read, write, delete, approve)
- Context: Environmental factors (time, location, device)
Policy: subject.id == resource.ownerId OR subject.role == 'admin'
Implementation with Custom Policies
// src/types/policy.ts
interface PolicyContext {
user: {
id: string;
role: string;
department?: string;
permissions: string[];
};
resource?: {
id: string;
type: string;
ownerId?: string;
status?: string;
[key: string]: unknown;
};
action: string;
environment?: {
ip?: string;
time?: Date;
userAgent?: string;
};
}
type PolicyDecision = 'allow' | 'deny';
type Policy = (ctx: PolicyContext) => PolicyDecision | null;
// src/lib/policy-engine.ts
class PolicyEngine {
private policies: Policy[] = [];
addPolicy(policy: Policy): void {
this.policies.push(policy);
}
evaluate(ctx: PolicyContext): PolicyDecision {
// Check each policy - first explicit allow/deny wins
for (const policy of this.policies) {
const decision = policy(ctx);
if (decision !== null) {
return decision;
}
}
// Default deny
return 'deny';
}
}
// Define policies
const policyEngine = new PolicyEngine();
// Admins can do anything
policyEngine.addPolicy((ctx) => {
if (ctx.user.role === 'admin') return 'allow';
return null; // Continue to next policy
});
// Users can read any published post
policyEngine.addPolicy((ctx) => {
if (
ctx.resource?.type === 'post' &&
ctx.action === 'read' &&
ctx.resource.status === 'published'
) {
return 'allow';
}
return null;
});
// Users can edit their own posts
policyEngine.addPolicy((ctx) => {
if (
ctx.resource?.type === 'post' &&
['update', 'delete'].includes(ctx.action) &&
ctx.resource.ownerId === ctx.user.id
) {
return 'allow';
}
return null;
});
// Users can only read their own draft posts
policyEngine.addPolicy((ctx) => {
if (
ctx.resource?.type === 'post' &&
ctx.action === 'read' &&
ctx.resource.status === 'draft' &&
ctx.resource.ownerId !== ctx.user.id
) {
return 'deny';
}
return null;
});
// src/middleware/abac.ts
export const checkAccess = (action: string, getResource?: (req) => Promise<unknown>) => {
return async (req, res, next) => {
const resource = getResource ? await getResource(req) : undefined;
const decision = policyEngine.evaluate({
user: req.user,
resource: resource as PolicyContext['resource'],
action,
environment: {
ip: req.ip,
time: new Date(),
userAgent: req.headers['user-agent'],
},
});
if (decision === 'deny') {
return res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'Access denied by policy',
},
});
}
req.resource = resource;
next();
};
};
// Usage
router.get(
'/posts/:id',
authenticate,
checkAccess('read', async (req) => {
return db.post.findUnique({
where: { id: req.params.id },
select: { id: true, ownerId: true, status: true },
});
}),
getPost
);
router.put(
'/posts/:id',
authenticate,
checkAccess('update', async (req) => {
return db.post.findUnique({
where: { id: req.params.id },
select: { id: true, ownerId: true, status: true },
});
}),
updatePost
);
Using CASL for Authorization
CASL is a popular JavaScript library for implementing authorization. It provides a clean DSL for defining abilities.
// src/lib/abilities.ts
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
type Subjects = 'Post' | 'Comment' | 'User' | 'all';
export type AppAbility = MongoAbility<[Actions, Subjects]>;
interface User {
id: string;
role: 'admin' | 'editor' | 'author' | 'viewer';
}
export const defineAbilitiesFor = (user: User): AppAbility => {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
switch (user.role) {
case 'admin':
can('manage', 'all'); // Admin can do anything
break;
case 'editor':
can('read', 'Post');
can('create', 'Post');
can('update', 'Post');
can('read', 'Comment');
can('update', 'Comment');
can('delete', 'Comment');
can('read', 'User');
break;
case 'author':
can('read', 'Post');
can('create', 'Post');
can('update', 'Post', { authorId: user.id }); // Only own posts
can('delete', 'Post', { authorId: user.id });
can('read', 'Comment');
can('create', 'Comment');
can('update', 'Comment', { authorId: user.id });
can('delete', 'Comment', { authorId: user.id });
break;
case 'viewer':
can('read', 'Post', { published: true }); // Only published posts
can('read', 'Comment');
break;
}
// Global restrictions
cannot('delete', 'User'); // Only admins (handled by 'manage all')
return build();
};
// src/middleware/casl.ts
import { ForbiddenError, subject } from '@casl/ability';
export const checkAbility = (
action: Actions,
subjectType: Subjects,
getSubject?: (req) => Promise<unknown>
) => {
return async (req, res, next) => {
const ability = defineAbilitiesFor(req.user);
let subjectInstance = subjectType;
if (getSubject) {
const data = await getSubject(req);
if (!data) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
subjectInstance = subject(subjectType, data);
req.resource = data;
}
try {
ForbiddenError.from(ability).throwUnlessCan(action, subjectInstance);
next();
} catch (error) {
res.status(403).json({
error: {
code: 'FORBIDDEN',
message: 'You are not allowed to perform this action',
},
});
}
};
};
// Usage
router.get('/posts', authenticate, checkAbility('read', 'Post'), listPosts);
router.put(
'/posts/:id',
authenticate,
checkAbility('update', 'Post', async (req) => {
return db.post.findUnique({ where: { id: req.params.id } });
}),
updatePost
);
router.delete(
'/posts/:id',
authenticate,
checkAbility('delete', 'Post', async (req) => {
return db.post.findUnique({ where: { id: req.params.id } });
}),
deletePost
);
CASL with Prisma
Filter database queries based on permissions:
// src/lib/casl-prisma.ts
import { accessibleBy } from '@casl/prisma';
import { AppAbility } from './abilities';
export const getAccessiblePosts = async (ability: AppAbility) => {
return db.post.findMany({
where: accessibleBy(ability).Post,
include: { author: true },
});
};
// Usage
router.get('/posts', authenticate, async (req, res) => {
const ability = defineAbilitiesFor(req.user);
const posts = await getAccessiblePosts(ability);
res.json({ data: posts });
});
Authorization in GraphQL
GraphQL requires field-level authorization since clients can request arbitrary field combinations.
// src/schema/resolvers.ts
import { ForbiddenError } from 'apollo-server-express';
import { defineAbilitiesFor } from '../lib/abilities';
const resolvers = {
Query: {
posts: async (_, __, ctx) => {
const ability = defineAbilitiesFor(ctx.user);
if (!ability.can('read', 'Post')) {
throw new ForbiddenError('Cannot read posts');
}
return db.post.findMany({
where: accessibleBy(ability).Post,
});
},
post: async (_, { id }, ctx) => {
const post = await db.post.findUnique({ where: { id } });
if (!post) return null;
const ability = defineAbilitiesFor(ctx.user);
if (!ability.can('read', subject('Post', post))) {
throw new ForbiddenError('Cannot read this post');
}
return post;
},
},
Mutation: {
updatePost: async (_, { id, input }, ctx) => {
const post = await db.post.findUnique({ where: { id } });
if (!post) {
throw new Error('Post not found');
}
const ability = defineAbilitiesFor(ctx.user);
if (!ability.can('update', subject('Post', post))) {
throw new ForbiddenError('Cannot update this post');
}
return db.post.update({ where: { id }, data: input });
},
},
// Field-level authorization
User: {
email: (user, _, ctx) => {
// Only show email if viewing own profile or admin
if (ctx.user.id === user.id || ctx.user.role === 'admin') {
return user.email;
}
return null;
},
posts: async (user, _, ctx) => {
const ability = defineAbilitiesFor(ctx.user);
// If viewing own posts, show all; otherwise only published
if (ctx.user.id === user.id) {
return db.post.findMany({ where: { authorId: user.id } });
}
return db.post.findMany({
where: {
authorId: user.id,
...accessibleBy(ability).Post,
},
});
},
},
};
GraphQL Directives for Authorization
Create custom directives for declarative authorization:
// src/directives/auth.ts
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
import { ForbiddenError } from 'apollo-server-express';
export const authDirectiveTransformer = (schema: GraphQLSchema) => {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const requiresAuth = getDirective(schema, fieldConfig, 'auth')?.[0];
const requiresRole = getDirective(schema, fieldConfig, 'hasRole')?.[0];
if (requiresAuth || requiresRole) {
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = async (source, args, context, info) => {
if (!context.user) {
throw new ForbiddenError('Authentication required');
}
if (requiresRole) {
const { role } = requiresRole;
if (context.user.role !== role && context.user.role !== 'admin') {
throw new ForbiddenError(`Role '${role}' required`);
}
}
return resolve(source, args, context, info);
};
}
return fieldConfig;
},
});
};
// Schema with directives
const typeDefs = `
directive @auth on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION
type Query {
posts: [Post!]! @auth
users: [User!]! @hasRole(role: "admin")
}
type Mutation {
createPost(input: CreatePostInput!): Post! @auth
deleteUser(id: ID!): Boolean! @hasRole(role: "admin")
}
`;
Multi-Tenant Authorization
In multi-tenant systems, users can only access resources within their organization.
// src/middleware/tenant.ts
export const tenantIsolation = async (req, res, next) => {
// User must belong to a tenant
if (!req.user.tenantId) {
return res.status(403).json({
error: {
code: 'NO_TENANT',
message: 'User is not associated with any organization',
},
});
}
// Add tenant filter to all database queries
req.tenantFilter = { tenantId: req.user.tenantId };
next();
};
// Apply to routes
router.use(authenticate, tenantIsolation);
router.get('/posts', async (req, res) => {
const posts = await db.post.findMany({
where: {
...req.tenantFilter, // Always filter by tenant
...accessibleBy(defineAbilitiesFor(req.user)).Post,
},
});
res.json({ data: posts });
});
// Prevent cross-tenant access on single resources
router.get('/posts/:id', async (req, res) => {
const post = await db.post.findFirst({
where: {
id: req.params.id,
...req.tenantFilter, // Tenant filter prevents cross-tenant access
},
});
if (!post) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
res.json({ data: post });
});
Database-Level Tenant Isolation
Use PostgreSQL Row-Level Security for defense in depth:
-- Enable RLS on posts table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Create policy for tenant isolation
CREATE POLICY tenant_isolation ON posts
USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- In your application, set tenant context
SET app.current_tenant_id = 'tenant-uuid-here';
-- Now all queries automatically filtered
SELECT * FROM posts; -- Only returns current tenant's posts
// src/lib/tenant-db.ts
export const withTenant = async <T>(
tenantId: string,
operation: () => Promise<T>
): Promise<T> => {
await db.$executeRaw`SET app.current_tenant_id = ${tenantId}`;
try {
return await operation();
} finally {
await db.$executeRaw`RESET app.current_tenant_id`;
}
};
// Usage
router.get('/posts', async (req, res) => {
const posts = await withTenant(req.user.tenantId, () =>
db.post.findMany()
);
res.json({ data: posts });
});
Common Gotchas
1. IDOR (Insecure Direct Object References)
Never trust client-provided IDs without checking access:
// BAD - No authorization check
router.get('/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });
res.json({ data: invoice }); // Any user can access any invoice!
});
// GOOD - Check ownership
router.get('/invoices/:id', async (req, res) => {
const invoice = await db.invoice.findFirst({
where: {
id: req.params.id,
OR: [
{ customerId: req.user.id },
{ tenantId: req.user.tenantId },
],
},
});
if (!invoice) {
return res.status(404).json({ error: { code: 'NOT_FOUND' } });
}
res.json({ data: invoice });
});
2. Mass Assignment Vulnerabilities
Don't allow users to set their own role:
// BAD - User can escalate privileges
router.put('/users/:id', async (req, res) => {
const user = await db.user.update({
where: { id: req.params.id },
data: req.body, // { name: "Alice", role: "admin" } - oops!
});
});
// GOOD - Whitelist allowed fields
router.put('/users/:id', async (req, res) => {
const { name, email, avatar } = req.body; // Only allow these fields
const user = await db.user.update({
where: { id: req.params.id },
data: { name, email, avatar },
});
});
3. Forgetting Soft Deletes
Soft-deleted resources should be inaccessible:
// Include deletedAt check in all queries
const posts = await db.post.findMany({
where: {
...req.tenantFilter,
deletedAt: null, // Don't forget this!
},
});
4. Caching Authorization Results
Be careful caching authorization decisions—they can change:
// BAD - Caching might serve stale permissions
const cachedPermissions = cache.get(`perms:${userId}`);
// BETTER - Short TTL or invalidate on role change
const cachedPermissions = cache.get(`perms:${userId}`, { ttl: 60 });
// On role change
await cache.delete(`perms:${userId}`);
See Also
- Authentication — OAuth flows, sessions, JWTs
- HTTP Best Practices — Status codes including 401 vs 403
- CASL Documentation
- OWASP Authorization Cheatsheet
- Oso Authorization Library