Multi-Tenant Authorization in TypeScript: CASL vs Custom ABAC
Add multi-tenant isolation to your permission system, evaluate CASL as a library alternative, and use decision frameworks to choose the right authorization architecture.
Post 101 established seven goals for a permission system and exposed the scattered-check anti-pattern. Post 102 centralized authorization inside a service layer. Post 103 added type-safe RBAC. Post 104 replaced the role-permission matrix with an ABAC policy engine. Post 105 extended ABAC with environment rules, field-level read/write permissions, and database query filtering.
Three production concerns remain. The system has no tenant boundary, so a user in Organization A can reach Organization B’s resources if the query is crafted correctly; that isolation belongs in the permission layer as a global condition, not scattered across every service method. The custom ABAC engine works, but nobody has decided whether the team keeps maintaining it or migrates to a library like CASL, and the engine stays until the team no longer wants to own it, which is the point where CASL earns its place. There is still no framework for choosing between RBAC, custom ABAC, library-based ABAC, and external policy engines, a choice that follows from how many contextual conditions each resource needs.
Multi-Tenancy Models#
The Tenant Concept#
In SaaS, a tenant is an organization, workspace, or account that groups users and their resources. Slack workspaces, GitHub organizations, and Notion workspaces are all tenants. The tenant boundary is the outermost permission boundary. Before checking roles, ownership, or field access, the system must verify the user belongs to the tenant that owns the resource.
Extending the Domain Model#
The series’ domain model gains a tenant dimension:
interface User {
userId: string;
role: Role;
departmentId?: string;
tenantId: string; // which organization this user belongs to
tenantRole?: TenantRole; // role within the tenant (owner, admin, member)
}
interface Document {
id: string;
title: string;
content: string;
authorId: string;
status: 'draft' | 'published' | 'archived';
projectId: string;
departmentId: string;
tenantId: string; // which tenant owns this document
}
interface Project {
id: string;
name: string;
ownerId: string;
departmentId: string;
tenantId: string; // which tenant owns this project
}
type TenantRole = 'owner' | 'admin' | 'member';
Every resource now carries a tenantId. Every user belongs to exactly one tenant. Membership in several tenants is possible, but it adds complexity outside this scope.
Three Isolation Strategies#
Row-Level Isolation (shared schema): All tenants share the same tables. Every table has a tenant_id column. This is the simplest infrastructure and cheapest option, but forgetting a WHERE tenant_id = ? clause leaks cross-tenant data. PostgreSQL Row-Level Security (RLS) can enforce this at the database level as a safety net.
Schema-Level Isolation: Each tenant gets a separate schema within the same database. Stronger isolation: a missing WHERE clause produces an error instead of a data leak. Migrations must run across N schemas. Viable for dozens to hundreds of tenants.
Database-Level Isolation: Each tenant gets a dedicated database instance. Maximum isolation and the strongest compliance posture. Highest cost and operational complexity.
| Aspect | Row-Level | Schema-Level | Database-Level |
|---|---|---|---|
| Infrastructure cost | Low | Medium | High |
| Isolation strength | Application-enforced | DB-schema-enforced | Physical isolation |
| Tenant count scalability | Thousands+ | Hundreds | Dozens |
| Migration complexity | Single migration | N migrations | N migrations + N databases |
| Cross-tenant query risk | High (missing WHERE) | Low (wrong schema = error) | None |
| Per-tenant customization | Limited | Moderate | Full |
| Compliance suitability | Standard | SOC 2 / ISO | HIPAA / PCI-DSS |
This series uses the row-level isolation model because it is the most common starting point and the most challenging from a permission perspective. Schema and database isolation solve tenant boundaries at the infrastructure level. Row-level isolation requires the application to enforce those boundaries.
Tenant-Aware Permission Layer#
The Scattered Tenant Check Anti-Pattern#
Without tenant-aware permissions, every service method manually checks tenant isolation:
// Anti-pattern: manual tenant check in every service method
async function getDocumentById(documentId: string) {
const session = await requireSession();
const document = await db.document.findUnique({ where: { id: documentId } });
// Manual tenant check, easy to forget
if (document.tenantId !== session.tenantId) {
throw new ForbiddenError();
}
// Then the regular ABAC check
if (!can(session, 'read', 'document', document)) {
throw new ForbiddenError();
}
return filterFields(session, 'document', document);
}
This is Post 101’s scattered-check pattern reappearing at the tenant level. Forgetting the tenant check on a single endpoint creates a cross-tenant data leak, the most dangerous class of authorization bug because it exposes other customers’ data.
Tenant Isolation as a Global ABAC Condition#
The correct approach: tenant isolation becomes a built-in condition that runs automatically for every permission check:
// Tenant isolation as a built-in global condition
const permissions = new PermissionBuilder()
// Global condition: applies to ALL roles, ALL resources
.global((user, data) => {
// Every resource must have a tenantId that matches the user's
if ('tenantId' in data && user.tenantId !== data.tenantId) {
return false; // Cross-tenant access: DENY
}
return true; // Same tenant: continue to role-specific checks
})
.role('admin')
.can('manage', 'document')
.can('manage', 'project')
.role('editor')
.can(['read', 'update'], 'document', [
(user, doc) => user.departmentId === doc.departmentId,
])
// ... remaining policies from Posts 104-105
.build();
The global() condition runs before any role-specific conditions. It acts as an implicit WHERE clause on every permission check. Even if a developer creates a new role or a new resource type, tenant isolation is automatically enforced.
Updating the can() Function#
The can() function evaluates global conditions first:
function can<R extends Resource>(
user: User,
action: Action,
resource: R,
data?: ResourceDataMap[R],
env?: Environment
): boolean {
// Step 1: Evaluate global conditions (tenant isolation)
if (data) {
for (const globalCondition of permissions.globalConditions) {
if (!globalCondition(user, data as Record<string, unknown>)) {
return false; // Global condition failed (e.g., wrong tenant)
}
}
}
// Step 2: Find matching entries for role + resource + action
// (same logic as Posts 104-105)
const entries = permissions[user.role] as PermissionEntry<R>[];
for (const entry of entries) {
if (entry.resource !== resource) continue;
if (!entry.actions.includes(action)) continue;
if (!entry.conditions || entry.conditions.length === 0) return true;
if (data) {
const allMet = entry.conditions.every(c => c.evaluate(user, data, env));
if (allMet) return true;
}
}
return false; // Deny by default
}
Updating Database Query Filtering#
The toWhereClause() function from Post 105 must include tenant filtering:
function toWhereClause<R extends Resource>(
user: User,
resource: R,
action: Action,
env?: Environment
): WhereClause<R> | null {
// Always include tenant filter
const tenantFilter = { tenantId: user.tenantId };
const roleFilter = buildRoleFilter(user, resource, action, env);
if (roleFilter === null) return null; // No access
// Combine tenant filter with role-specific filter
return { ...tenantFilter, ...roleFilter };
}
The tenant filter is always present. Even if buildRoleFilter() returns {} (no additional filter for an admin), the query still includes WHERE tenantId = ?.
Cross-Tenant Access Exceptions#
Some scenarios require cross-tenant access:
- Platform admins (super-admins) who manage all tenants
- Shared resources (templates, public content) that exist outside any tenant
- Support tools for customer service to view tenant data
// Platform admin bypasses tenant isolation
.role('platform_admin')
.global(() => true) // Override global tenant check
.can('manage', 'document')
.can('manage', 'project')
.can('manage', 'tenant')
// Shared resources have no tenantId
interface SharedTemplate {
id: string;
title: string;
// No tenantId, so every tenant can reach it
}
Warning
Cross-tenant exceptions must be explicit and auditable. The platform admin role should be separate from tenant-level admin, with additional authentication requirements (MFA, IP restrictions) enforced via environment conditions from Post 105.
Why Use a Permission Library?#
The Build vs. Library Decision#
Posts 101-105 built a custom permission system covering RBAC, ABAC, field-level permissions, DB query filtering, environment rules, and now multi-tenancy. This is approximately 300-500 lines of core permission logic. At what point does maintaining this code become more expensive than adopting a library?
What Each Path Costs the Team#
Building it yourself buys zero dependencies in the critical security path, full control over the can() signature as it evolves, and TypeScript integration tailored to the exact domain (generic constraints, builder patterns, type inference). Nothing serializes: plain functions, no class instances, RSC-compatible by construction. Every condition is a function the team wrote, so there are no black boxes, and debugging follows a normal call stack. The cost sits on the same team, though: it owns every bug, edge case, and security patch, without the community testing a widely used library accumulates, and field permissions, DB query conversion, and condition operators ($in, $ne, $gte) all get rebuilt from scratch while new hires learn a bespoke API instead of a documented one.
A permission library arrives community-tested across thousands of projects, with field permissions, MongoDB-style conditions, and Prisma/Mongoose adapters already built, and documentation, Stack Overflow answers, and conference talks exist because the team didn’t have to write them. What it costs is control: the library’s API may not match the series’ can() signature, its maintenance can slow or stop, class-based designs clash with React Server Components, and debugging a permission denial means understanding someone else’s internals rather than a function the team wrote.
Build vs. Library Decision Framework#
In-Code vs. DSL-Based Approaches#
| Approach | Examples | Strengths | Weaknesses |
|---|---|---|---|
| In-code (TypeScript) | Custom, CASL | Type-safe, no runtime overhead, familiar language | Coupled to deployment, no runtime changes |
| DSL / Policy language | OPA/Rego, Cedar, Cerbos (YAML) | Decoupled from app code, non-dev editable, auditable | Learning curve, tooling overhead, latency |
| Hybrid | Permit.io, custom DB-stored rules | Runtime-configurable + code-based defaults | Complexity, consistency challenges |
CASL Integration#
Why CASL#
CASL is the most popular JavaScript/TypeScript authorization library (~6KB core). It is isomorphic (works on server and client), supports ABAC conditions, field-level permissions, and database query conversion. Since the series has already built everything CASL provides, a direct feature-for-feature comparison is possible.
npm install @casl/ability @casl/prisma
Migration: AbilityBuilder#
The custom PermissionBuilder from Post 104 maps to CASL’s AbilityBuilder:
Custom (Posts 104-105):
const permissions = new PermissionBuilder()
.role('admin')
.can('manage', 'document')
.role('editor')
.can(['read', 'update'], 'document', [
(user, doc) => user.departmentId === doc.departmentId,
])
.role('author')
.can(['read', 'update'], 'document', [
(user, doc) => doc.authorId === user.userId,
])
.build();
CASL equivalent:
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage';
type Subjects = 'Document' | 'Project' | 'all';
type AppAbility = MongoAbility<[Actions, Subjects]>;
function defineAbilitiesFor(user: User): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility
);
if (user.role === 'admin') {
can('manage', 'all');
}
if (user.role === 'editor') {
can(['read', 'update'], 'Document', { departmentId: user.departmentId });
}
if (user.role === 'author') {
can(['read', 'update'], 'Document', { authorId: user.userId });
can('create', 'Document');
}
if (user.role === 'viewer') {
can('read', 'Document', { status: 'published' });
}
return build();
}
Key API differences:
- Conditions are MongoDB-style objects (
{ authorId: user.userId }); the custom builder used functions - No builder-pattern chaining for roles; it uses
if/elsebranching on user role cannot()for negative rules (CASL exclusive; the custom system did not have this)'manage'is CASL’s wildcard for all CRUD actions;'all'for all subjects
The subject() Helper and Its Friction#
CASL needs to know the type of an object being checked. With classes, this is automatic (via the class name). With plain objects, which TypeScript applications typically use, the subject() helper is required:
import { subject } from '@casl/ability';
// CASL requires wrapping plain objects
ability.can('update', subject('Document', document));
// Problem: subject() mutates the object by adding __caslSubjectType__
// This conflicts with React Server Components (objects must be serializable)
Workaround 1: Object spreading
// Create a copy to avoid mutating the original
ability.can('update', subject('Document', { ...document }));
Workaround 2: Custom detectSubjectType
import { createMongoAbility } from '@casl/ability';
const ability = createMongoAbility(rules, {
detectSubjectType: (object) => {
// Use a custom property instead of class name
return object.__type || object.constructor?.modelName || 'unknown';
},
});
// In the service layer, add __type to returned objects
function toDocumentDTO(doc: Document): DocumentDTO & { __type: 'Document' } {
return { ...doc, __type: 'Document' };
}
Workaround 3: PureAbility with lambda matcher (RSC-compatible)
import {
PureAbility,
AbilityBuilder,
type AbilityTuple,
type MatchConditions,
} from '@casl/ability';
type AppAbility = PureAbility<AbilityTuple, MatchConditions>;
const lambdaMatcher = (matchConditions: MatchConditions) => matchConditions;
function defineAbilityFor(user: User): AppAbility {
const { can, build } = new AbilityBuilder<AppAbility>(PureAbility);
// Lambda conditions instead of MongoDB-style: works without classes
can('read', 'Document', ({ authorId }) => authorId === user.userId);
return build({ conditionsMatcher: lambdaMatcher });
}
Tip
The PureAbility + lambda matcher approach is the most RSC-compatible option, but it loses CASL’s MongoDB-style query operators and Prisma integration. There is a real tradeoff between CASL’s full feature set and modern React compatibility.
Tenant Isolation in CASL#
function defineAbilitiesFor(user: User): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(
createMongoAbility
);
// Tenant isolation: tenantId must be added to EVERY rule
// CASL does not have a global() condition
if (user.role === 'admin') {
can('manage', 'Document', { tenantId: user.tenantId });
can('manage', 'Project', { tenantId: user.tenantId });
}
if (user.role === 'editor') {
can(['read', 'update'], 'Document', {
tenantId: user.tenantId,
departmentId: user.departmentId,
});
}
// Platform admin: no tenantId filter
if (user.role === 'platform_admin') {
can('manage', 'all');
}
return build();
}
The custom system had a global() condition that applied tenant isolation automatically to every rule. CASL requires adding tenantId to every rule individually, and missing it on one rule creates a cross-tenant leak.
CASL Field and DB Integration#
Field-Level Permissions with permittedFieldsOf#
import { permittedFieldsOf } from '@casl/ability/extra';
// Define field-level rules
can('read', 'Document', ['title', 'content', 'status'], {
status: 'published',
});
can(
'read',
'Document',
['title', 'content', 'status', 'internalNotes', 'reviewComments'],
{ authorId: user.userId }
);
// Get permitted fields for a specific document
const fields = permittedFieldsOf(ability, 'read', 'Document', {
fieldsFrom: (rule) =>
rule.fields || [
'title',
'content',
'status',
'authorId',
'internalNotes',
'reviewComments',
'publishedAt',
],
});
Compare to the custom getVisibleFields() from Post 105: the concept is the same, the API is different. CASL requires a fieldsFrom callback that returns all possible fields when a rule has no field restriction.
CASL AST to Prisma Query Conversion#
import { accessibleBy } from '@casl/prisma';
// Convert CASL rules to Prisma where clause
const documents = await prisma.document.findMany({
where: accessibleBy(ability).Document,
});
// Combine with business logic filters
const documents = await prisma.document.findMany({
where: {
AND: [accessibleBy(ability).Document, { projectId: projectId }],
},
});
Compare to the custom toWhereClause() from Post 105:
- CASL’s
accessibleBy()converts MongoDB-style conditions into Prismawheresyntax - The custom
toWhereClause()uses condition descriptors withtoFiltercallbacks - CASL automatically handles
ORlogic across multiple matching rules - CASL throws
ForbiddenErrorif no rules match at all (fail-closed)
Warning
accessibleBy() only works with MongoDB-style conditions (from createMongoAbility), not with lambda conditions (PureAbility). If you use the RSC-compatible PureAbility pattern, you lose Prisma query conversion.
Choosing Between Four Approaches#
RBAC vs. Custom ABAC vs. CASL ABAC#
| Dimension | RBAC (Post 103) | Custom ABAC (Posts 104-105) | CASL ABAC (Post 106) |
|---|---|---|---|
| Core logic | Role-permission lookup | Policy engine with conditions | AbilityBuilder with MongoDB conditions |
| Lines of auth code | ~80 (matrix + can()) | ~300-500 (builder + engine + field + DB) | ~50 (defineAbilitiesFor) + library |
can() signature | can(role, resource, action) | can(user, action, resource, data?, env?) | ability.can(action, subject(type, data)) |
| Contextual conditions | No (requires helpers) | Yes (inline in policy builder) | Yes (MongoDB-style objects or lambdas) |
| Field-level permissions | No | Yes (getVisibleFields, pickPermittedFields) | Yes (permittedFieldsOf) |
| DB query filtering | No | Yes (toWhereClause()) | Yes (accessibleBy() with Prisma) |
| Environment rules | No | Yes (time, IP, flags) | Partial (via custom conditions) |
| Multi-tenancy | Manual check per method | Global condition (automatic) | Per-rule tenantId (manual per rule) |
| Type safety | Full (generics, mapped types) | Full (resource-action generics) | Good (typed actions/subjects, weaker on conditions) |
| RSC compatibility | Full (plain functions) | Full (plain functions) | Partial (subject() mutation issue) |
| Negative rules | No | No | Yes (cannot()) |
| Maintenance | Team-owned | Team-owned | Library-maintained core |
| Bundle size | 0 (built-in) | 0 (built-in) | ~6KB (core) + adapters |
Decision Framework: Which System Should You Choose?#
When to Choose Each#
RBAC (Post 103)
It works at any team size, for internal tools, simple SaaS, and content platforms with clear roles: 2-4 roles, permissions depending only on role. Choose it when permission requirements map cleanly to “this role can do these things.”
A team with authorization expertise, willing to maintain its own auth code, reaches for this when building SaaS with complex business rules, field-level visibility, and large datasets: ownership, department, status, and time conditions all feed into can(), which by now evaluates 3+ contextual conditions per resource. That balance shifts once team bandwidth for auth maintenance drops, or DB query adapters are needed across multiple ORMs.
CASL ABAC (Post 106)
CASL suits a team that would rather spend its time on business logic than on auth internals: building SaaS on Prisma/MongoDB that needs field permissions and DB filtering, trading custom code for a library API the team prefers once the custom ABAC feature set already matches what CASL provides. Heavy RSC usage with plain objects, a need for environment conditions, or a need for global tenant isolation rule it out.
External PDP (Cerbos, OPA, Cedar)
A dedicated platform or security team gets the most value here, typically in a microservices or polyglot stack, when several backends need the same authorization decisions and compliance requires decoupled, auditable policy management.
Where Multi-Tenant Authorization Breaks#
-
Forgetting tenant isolation on one CASL rule: CASL has no global condition. Missing
tenantIdon one rule creates a cross-tenant leak. Write a lint rule or unit test that verifies every non-platform-admin rule includestenantId. -
Assuming CASL works seamlessly with RSC: The
subject()helper mutates objects. React Server Components require serializable data. Use one of the three workarounds from the CASL Integration section. -
PureAbility loses Prisma integration: The RSC-compatible
PureAbility+ lambda matcher pattern cannot convert conditions to Prismawhereclauses. Teams must choose between RSC compatibility and DB query filtering. -
Over-engineering early: Jumping to ABAC or CASL before RBAC fails is premature.
-
Multi-tenancy as an afterthought: Adding
tenant_idto every table after the schema is established is a painful migration. Design tenant isolation from the beginning, even if the first version has only one tenant. -
Confusing platform admin with tenant admin: Platform admins manage all tenants (cross-tenant access). Tenant admins manage their own tenant only. Mixing these roles creates either overly permissive tenant admins or insufficiently permissive platform admins.
-
Choosing an external PDP too early: Cerbos, OPA, and Cedar add infrastructure complexity. For a monolithic Next.js app, in-process authorization (custom or CASL) is simpler and faster. External PDPs make sense when authorization decisions must be shared across independently deployed services.
-
Not testing cross-tenant scenarios: Unit tests often use a single tenant ID. Add explicit test cases where User A (tenant 1) attempts to access User B’s document (tenant 2). These tests catch missing tenant filters.
Series Retrospective#
The Seven Goals Scorecard#
Post 101 established seven goals for any permission system. Here is how each approach scores:
| Goal | Scattered (101) | Service Layer (102) | RBAC (103) | Custom ABAC (104-105) | CASL (106) |
|---|---|---|---|---|---|
| Prevent unauthorized access | Partial | Yes | Yes | Yes | Yes |
| Consistent (single source of truth) | No | Yes | Yes | Yes | Yes |
| Auto-enforce | No | Architectural | Architectural | Architectural + Global | Architectural |
| Easy to update | No | Moderate | Yes (matrix) | Yes (builder) | Yes (rules) |
| Auditable | No | Moderate | Yes (matrix) | Yes (builder) | Yes (rules) |
| Performant | Varies | Yes + cache | Yes (O(1) lookup) | Yes (condition eval) | Yes (condition eval) |
| Type-safe | No | Partial | Full | Full | Good |
Series Architecture Evolution#
The service layer from Post 102 never changes across all six posts. It stays the enforcement point for every authorization approach while the decision engine inside it evolves from simple role checks to RBAC to ABAC to CASL, so each upgrade stays contained within that one layer.
Authorization Across Service Boundaries#
The series focused on a monolithic Next.js application. As applications grow, authorization decisions must work across service boundaries, typically through one of three patterns:
| Pattern | Mechanism | Trade-off |
|---|---|---|
| Centralized Authorization Service | A single service evaluates all permission decisions; other services call it via gRPC/HTTP | Single source of truth, but a single point of failure with network latency on every request |
| Embedded PDP (Sidecar) | Each microservice runs its own policy engine (OPA sidecar, Cerbos sidecar), synced centrally by a policy manager | No network hop for decisions, but policy sync complexity and version-drift risk |
| Token-Based Claims | Authorization data is embedded in JWT claims (roles, permissions, tenantId); services trust the token without additional checks | Simplest infrastructure, but stale claims and no resource-level authorization |
For teams moving from monolith to microservices: start with Pattern 3 (token claims) for service-to-service auth, and add Pattern 2 (embedded PDP) when fine-grained resource-level authorization is needed across services.
Permission Storage: Code vs. Database#
| Approach | Strengths | Weaknesses | When to Use |
|---|---|---|---|
| Code-only (this series) | Type-safe, version-controlled, CI/CD testable | Requires deployment for changes | Permission rules change with app code |
| Database-stored | Runtime-configurable, tenant-customizable | No compile-time safety, migration complexity | Tenants need custom roles/permissions |
| Hybrid | Default rules in code + overrides in DB | Complexity of two systems, conflict resolution | SaaS with per-tenant customization |
The hybrid pattern works well for production SaaS: define the default permission set in code (type-safe, tested), allow tenants to override specific rules via a database table. The can() function checks code-based rules first, then applies database overrides.
Series Recap#
Over six posts, the permission system evolved from scattered if-statements to a production-grade authorization architecture:
- Post 101: Identified the problem: scattered checks, inconsistent enforcement, no fail-closed default
- Post 102: Established the architecture: service layer as the single enforcement point
- Post 103: Added the first decision engine: type-safe RBAC with generic constraints
- Post 104: Replaced role-based lookup with attribute-based policies: ownership, department, status conditions
- Post 105: Extended ABAC with environment rules, field-level permissions, and database query filtering
- Post 106: Added multi-tenancy, evaluated CASL as a library alternative, and set out the decision framework
Default to RBAC while permission rules still map cleanly onto roles, and move past it once a single resource needs three or more contextual conditions in can().
References#
- CASL - Isomorphic Authorization JavaScript Library (opens in new tab) - Source repository for CASL, covering ABAC conditions, field-level permissions, and database query conversion
- CASL v7 - Prisma Integration (opens in new tab) - Official documentation for
@casl/prismaincluding theaccessibleBy()function for converting CASL rules into Prismawhereclauses - CASL v7 - Restricting Fields Access (opens in new tab) - Official CASL documentation on
permittedFieldsOf()and field arrays in rule definitions - Shipping Multi-Tenant SaaS Using PostgreSQL Row-Level Security (Nile) (opens in new tab) - Guide to implementing row-level security for multi-tenant SaaS including tenant context propagation and fail-secure defaults
- The Developer’s Guide to SaaS Multi-Tenant Architecture (WorkOS) (opens in new tab) - Architectural overview of multi-tenancy models with decision criteria based on isolation requirements and tenant count
- How to Choose the Right Authorization Model for Your SaaS (WorkOS) (opens in new tab) - Decision framework for selecting between roles, permissions, ABAC, ReBAC, and policy-based authorization
- Policy Engines: OPA vs Cedar vs Zanzibar (Permit.io) (opens in new tab) - Comparative analysis of OPA (Rego-based), Cedar (AWS, formal verification), and Zanzibar (Google, graph-based ReBAC)
- 3 Most Common Authorization Designs for SaaS Products (Cerbos) (opens in new tab) - Comparison of ACL, RBAC, and ABAC patterns with guidance on when to use each
- Multi-Tenant Data Isolation with PostgreSQL Row Level Security (AWS) (opens in new tab) - AWS guide to implementing RLS for tenant isolation in PostgreSQL
- Best Practices for Authorization in Microservices (Permit.io) (opens in new tab) - Guide covering centralized vs. embedded PDP patterns and the recommended sidecar architecture
- RBAC vs ABAC: Main Differences and When to Use Each (Oso) (opens in new tab) - Comparison of RBAC and ABAC models with decision criteria for hybrid approaches
- OWASP Microservices Security Cheat Sheet (opens in new tab) - OWASP guidance on microservices authorization patterns including centralized PDP and embedded PDP sidecar
- An Introduction to Google Zanzibar and ReBAC (Authzed) (opens in new tab) - Overview of Google Zanzibar’s relationship-based access control model and how it powers authorization at scale
Permission Systems that Scale
A comprehensive guide to building scalable permission systems in TypeScript and Next.js, progressing from naive checks through RBAC and ABAC to production-grade multi-tenant authorization.
All posts in this series
Related posts
Build SaaS authorization with AWS Cognito and Verified Permissions, covering Cedar policies, multi-tenant patterns, JWT flow, and cost in TypeScript.
authorization · aws · authentication +4
Authentication vs authorization, common permission pitfalls, the fail-closed principle, and the goals every permission system should meet.
typescript · nextjs · authorization +2
Refactor scattered permission checks into a centralized service layer, add Next.js middleware guards, and build a defense-in-depth authorization architecture.
typescript · nextjs · authorization +2
Build a type-safe RBAC system in TypeScript, create a unified can() function, sync permissions across UI and backend, and learn when RBAC reaches its limits.
typescript · nextjs · authorization +2
Build an ABAC policy engine in TypeScript with the builder pattern, conditional permissions, and type-safe policy evaluation that replaces RBAC's limitations.
typescript · nextjs · authorization +2