Sitemap
Press enter or click to view image in full size

Multi-tenant CASL: the patterns that survive production

--

Authorization tutorials end where the real problems start. Here’s what we learned at scale.

CASL is wonderful in tutorials. You define an ability, you check it with ability.can('read', 'Post'), the user either gets through or doesn't. The README examples are clean. The TypeScript inference is good. You ship.

Then your platform crosses a threshold — multiple orgs, hundreds of permissions, real volume — and the same patterns that felt elegant become the bottleneck. Your dashboard renders in four seconds because every list view fans out into 200 ability checks. Your audit logs are missing context. Your customer support team gets the same denial error for completely different reasons. Your DBA pages you because an accessibleBy query produced a 300-line SQL with a chain of correlated subqueries.

This is the post about what to do after the tutorial ends. Some of these patterns we found by reading the CASL source, some by debugging at 2am, and one by literally rewriting our authorization layer twice.

What CASL does well

Before any complaints: CASL gets the language of permissions right. subject and action and condition map cleanly to "who can do what to which resource." Compared to roll-your-own role checks scattered across controllers, CASL is a meaningful upgrade. The ergonomics for simple cases are excellent. The serialization into a portable format means front-end and back-end can share the same logic without duplicating intent.

That’s not what this post is about. This is about what happens when “simple cases” stops describing your business.

Where it breaks at scale

1. N+1 in ability checks

The default pattern in most CASL tutorials looks like this:

const posts = await postRepo.find({ where: { orgId } });
const visiblePosts = posts.filter(p =>
ability.can('read', subject('Post', p))
);
return visiblePosts;

Fine for ten posts. At a thousand posts per page, with conditions that involve nested resources (a post belongs to a project, the project belongs to a workspace, the workspace has a feature flag), each ability check ends up triggering one or more lazy lookups. You get a list view that does one query plus a thousand ability checks plus N lookups per check.

The fix is to either pre-fetch everything ability checks need (eager loading the entire dependency tree) or to push the filtering into the database layer using accessibleBy. Both have costs. Eager loading wastes work. accessibleBy produces SQL you may not love.

2. accessibleBy turns into subselect inferno

CASL’s accessibleBy adapter for TypeORM/Prisma converts ability conditions into WHERE clauses. For simple flat conditions ({ orgId: user.orgId }), the SQL is great. For nested conditions involving relations, it becomes correlated subqueries the planner cannot flatten:

WHERE post.id IN (
SELECT post.id FROM post
WHERE post.project_id IN (
SELECT project.id FROM project
WHERE project.workspace_id IN (
SELECT workspace.id FROM workspace WHERE ...
)
)
)

For three levels of nesting on indexed columns, PostgreSQL handles it. For five levels on a multi-tenant table with 50 million rows, you’re looking at eight-second queries.

The pattern that worked: denormalize the org boundary onto every table. Every multi-tenant table gets an org_idcolumn that's enforced by the application and indexed. Conditions in CASL flatten to orgId === user.orgId, which is one index lookup. The denormalization is a real cost (writes go to two columns instead of one) but it changes authorization queries from inferno to instant.

3. Cache invalidation when policies change

If you cache compiled abilities per user (which you should — see the next section), changing a permission for that user requires invalidating the cache. Easy. Changing a role used by 10,000 users requires invalidating 10,000 cache entries. Changing a system-level policy used by every user requires invalidating everything.

Two approaches we tried:

  1. Per-user cache with a version stamp at the org level. Bumping the org’s version invalidates everything for that org with no enumeration. Simple, fast, slightly wasteful.
  2. Topic-based invalidation via Redis pub/sub. More precise, more complex, more failure modes.

We kept #1. The “wasteful” invalidation is invisible at human scale and the implementation is thirty lines.

4. Conditions that mix data and identity

A common antipattern: conditions like { assignee: '$user.id' }. The $user.id is a placeholder CASL expands at check time. This is convenient but couples your policy to your authentication context in a way that breaks when you want to evaluate the policy in a different scope (e.g., "what would this user be able to see if they were impersonating org X?").

The pattern that scales: separate identity context (who is asking) from policy evaluation context (what are we evaluating against). Compile abilities at the request boundary using a fully-resolved identity context. Don’t let placeholders leak into the cached policy.

The patterns that scale

Pre-compile abilities at request boundary

Every authenticated request goes through a middleware that:

  1. Resolves the user, their org, their roles, and their org-level overrides
  2. Compiles a CASL Ability instance for this specific request
  3. Attaches it to the request context

Subsequent ability checks within the request are zero-cost — they’re checking against an already-built rule list. Don’t compile abilities inside controllers, services, or per-row. Compile once per request.

// middleware
req.ability = compileAbilityForUser(req.user);
// controller
@Get('posts')
async list(@Request() req) {
const posts = await postRepo.findAll({ orgId: req.user.orgId });
return posts.filter(p =>
req.ability.can('read', subject('Post', p))
);
}

If filtering is heavy, push it into the query with accessibleBy — but only for flat conditions (see point 2 above).

Materialize scope filters at query time

For high-cardinality lists, don’t filter post-fetch. Translate the ability into a query filter once, pass it to the query layer:

const orgScope = scopeFromAbility(req.ability, 'Post');
const posts = await postRepo.findAll({ ...orgScope, ...userFilters });

scopeFromAbility returns a flat WHERE-friendly object (e.g., { orgId: 'abc', authorId: 'xyz' }). The function is small and explicit; it doesn't try to handle every CASL condition. For complex conditions that don't translate, fall back to post-fetch filtering with a code comment explaining why.

This is also where you encode the “default deny” — if scopeFromAbility returns an empty scope for a user who shouldn't see anything, the query returns nothing, with no further authorization needed.

Field-level redaction is a separate concern

CASL has field-level abilities (can('read', 'Post', ['title', 'body'])). They work but they bind row access and field access in the same rule, which makes per-org column redaction (a compliance requirement in regulated industries) hard to express cleanly.

Our pattern: row-level access via CASL, field-level redaction via a separate transformer. The transformer takes a row and the user’s redaction policy and returns a redacted version. It’s invoked in serializers, in audit log writers, and in export jobs — same logic, three call sites.

// row access — CASL
if (!ability.can('read', subject('Call', call))) throw new ForbiddenError();
// field redaction — separate concern
return redactFields(call, redactionPolicyFor(req.user.org));

This separation lets us version row policies and redaction policies independently. Org A can change “what fields are exported” without touching “who can read the row.”

Per-org policy precedence is explicit, not implicit

CASL conditions can be combined with inverted rules ("can read except X"), but the precedence of multiple rules from different sources (system policy, role default, org override, user override) gets unclear fast. We stopped trying to express precedence in the rule list itself and started building the rule list deterministically.

function compileAbilityForUser(user: User): Ability {
const rules: Rule[] = [
...systemDefaultRules(), // base
...roleRules(user.roles), // additive
...orgOverrides(user.org, user.roles), // additive or restrictive
...userOverrides(user.id), // last word
];
return new Ability(rules);
}

The order of array concatenation is the precedence. There’s no clever interaction. Anyone reading compileAbilityForUser can trace the resolution in one direction. When something goes wrong, you debug the array, not the rule engine.

Counter-arguments

“This is too much for our team.” If you have <10 permissions and a single tenant, yes. If you have multi-tenant + role + org-override + system-level policies, the patterns above cost less than the bugs you’ll ship without them.

“Just use OPA / Casbin / your-own-thing.” Maybe. But CASL’s serializable rules let you share logic between front-end and back-end without an external service, which matters more than people admit. Most “just use X” rewrites don’t survive contact with the existing codebase.

“Pre-compiling abilities is wasteful for unauthenticated routes.” It is. Skip the middleware on those routes; don’t compile what you don’t use.

“Denormalizing org_id everywhere is bad data modeling.” It’s deliberate redundancy in service of a specific query path. The alternative — joining to determine ownership on every read — is worse. We catch divergence (an org_id mismatch between a row and its parent) with a periodic invariant check, not by avoiding the denormalization.

When CASL is overkill

Skip it if:

  • You have one tenant, one role, three permissions
  • Your authorization is “owner only” or “anyone authenticated”
  • Your team is two engineers without time to maintain a permission system

In those cases, CASL’s flexibility is a tax, not a feature.

Closing

CASL gives you the grammar of authorization. Production gives you the failure modes. The patterns above are not innovations — they’re what you arrive at after debugging slow dashboards, audit log gaps, and 2am pages from your DBA.

Compile abilities once per request. Push filtering into the database when you can flatten conditions. Separate row access from field redaction. Build precedence as a sequence, not a rule interaction.

If you’re using CASL today and one of these problems sounds familiar, you’re not doing CASL wrong. You hit the place where the tutorials end and production begins.

I’m a senior software engineer working on multi-tenant control planes for AI voice-agent platforms. Currently building infrastructure that handles tens of thousands of calls per day in regulated industries (healthcare, finance, insurance). Available for senior freelance engagements — find me on LinkedIn or GitHub.

--

--

Matheus Lúcio
Matheus Lúcio

Written by Matheus Lúcio

Passionate Software Engineer with 12+ years' experience, skilled in Node.js, and React. Expert in scalable, innovative tech solutions. https://mathz.dev