TL;DR
CASL is the most popular isomorphic authorization library — define abilities as "can user X do Y on resource Z?", works in both Node.js and React, integrates with Prisma/MongoDB/Mongoose. Casbin is the policy engine — supports RBAC, ABAC, ACL, and custom models via a configuration DSL, used in enterprise systems across many languages. accesscontrol is the simpler RBAC library — role-based grant definitions with resource/action/possession patterns. In 2026: CASL for TypeScript-first projects, Casbin for complex multi-model authorization, accesscontrol for simple RBAC.
Key Takeaways
- CASL: ~500K weekly downloads — TypeScript-first, isomorphic (Node + browser), Prisma/Mongoose integration
- Casbin: ~200K weekly downloads — policy engine, PERM model, supports 15+ languages, admin UI
- accesscontrol: ~200K weekly downloads — simple RBAC, role hierarchy, grant chains
- CASL defines abilities:
can("read", "Package"),cannot("delete", "Package", { published: true }) - Casbin separates model (WHO can do WHAT) from policy (specific rules) — very flexible
- accesscontrol uses grant notation:
ac.grant("admin").createAny("package")
Authorization vs Authentication
Authentication: WHO are you?
→ Login, JWT, session, OAuth
→ Libraries: Passport, NextAuth, Lucia, better-auth
Authorization: WHAT can you do?
→ Permissions, roles, policies
→ Libraries: CASL, Casbin, accesscontrol (this article)
RBAC (Role-Based Access Control):
User → has Role → Role grants Permissions
"admin can delete any package"
ABAC (Attribute-Based Access Control):
User attributes + Resource attributes + Context → Permission decision
"user can edit package IF user.id === package.authorId AND package.status !== 'published'"
CASL
CASL — isomorphic authorization:
Define abilities
import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"
type Actions = "create" | "read" | "update" | "delete" | "manage"
type Subjects = "Package" | "User" | "Comment" | "all"
type AppAbility = MongoAbility<[Actions, Subjects]>
function defineAbilitiesFor(user: { id: string; role: string }) {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility)
switch (user.role) {
case "admin":
can("manage", "all") // Admin can do everything
break
case "editor":
can("read", "Package")
can("create", "Package")
can("update", "Package") // Can update any package
cannot("delete", "Package", { published: true }) // Can't delete published
can("read", "Comment")
can("delete", "Comment", { authorId: user.id }) // Own comments only
break
case "viewer":
can("read", "Package")
can("read", "Comment")
can("create", "Comment")
can("update", "Comment", { authorId: user.id }) // Own comments only
can("delete", "Comment", { authorId: user.id })
break
default:
can("read", "Package") // Public: read-only
}
return build()
}
Check permissions
const ability = defineAbilitiesFor({ id: "user-123", role: "editor" })
// Check:
ability.can("read", "Package") // true
ability.can("delete", "Package") // true (unpublished)
ability.cannot("delete", subject("Package", { published: true })) // true
// In Express middleware:
function authorize(action: Actions, subject: Subjects) {
return (req, res, next) => {
const ability = defineAbilitiesFor(req.user)
if (ability.can(action, subject)) {
next()
} else {
res.status(403).json({ error: "Forbidden" })
}
}
}
app.delete("/api/packages/:id", authorize("delete", "Package"), deleteHandler)
With Prisma (CASL + Prisma integration)
import { accessibleBy } from "@casl/prisma"
// Automatically filter database queries by user permissions:
app.get("/api/packages", async (req, res) => {
const ability = defineAbilitiesFor(req.user)
// Only returns packages the user CAN read:
const packages = await prisma.package.findMany({
where: accessibleBy(ability).Package,
})
res.json(packages)
})
// Editor sees all packages
// Viewer sees only non-draft packages
// The WHERE clause is generated automatically from CASL abilities
React integration (isomorphic)
import { Can } from "@casl/react"
function PackageCard({ pkg, ability }) {
return (
<div>
<h3>{pkg.name}</h3>
<Can I="update" a="Package" ability={ability}>
<button onClick={() => editPackage(pkg.id)}>Edit</button>
</Can>
<Can I="delete" a="Package" ability={ability}>
<button onClick={() => deletePackage(pkg.id)}>Delete</button>
</Can>
</div>
)
}
Casbin
Casbin — policy engine:
Model definition (PERM)
# model.conf — RBAC model:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Policy rules
# policy.csv:
p, admin, package, read
p, admin, package, write
p, admin, package, delete
p, admin, user, read
p, admin, user, write
p, editor, package, read
p, editor, package, write
p, editor, comment, read
p, editor, comment, write
p, viewer, package, read
p, viewer, comment, read
p, viewer, comment, write
# Role hierarchy:
g, alice, admin
g, bob, editor
g, charlie, viewer
Node.js usage
import { newEnforcer } from "casbin"
// Load model and policy:
const enforcer = await newEnforcer("model.conf", "policy.csv")
// Check permission:
const allowed = await enforcer.enforce("alice", "package", "delete")
// true — alice is admin, admin can delete packages
const denied = await enforcer.enforce("charlie", "package", "write")
// false — charlie is viewer, viewers can't write packages
// Express middleware:
function casbinAuth(obj: string, act: string) {
return async (req, res, next) => {
const allowed = await enforcer.enforce(req.user.username, obj, act)
if (allowed) {
next()
} else {
res.status(403).json({ error: "Forbidden" })
}
}
}
app.delete("/api/packages/:id", casbinAuth("package", "delete"), deleteHandler)
ABAC model (attribute-based)
# model.conf — ABAC:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub_rule, obj_rule, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = eval(p.sub_rule) && eval(p.obj_rule) && r.act == p.act
// Check with attributes:
const allowed = await enforcer.enforce(
{ role: "editor", department: "engineering" },
{ type: "package", status: "draft" },
"delete"
)
Database adapter (persist policies)
import { newEnforcer } from "casbin"
import TypeORMAdapter from "typeorm-adapter"
// Store policies in PostgreSQL:
const adapter = await TypeORMAdapter.newAdapter({
type: "postgres",
host: "localhost",
port: 5432,
database: "casbin",
})
const enforcer = await newEnforcer("model.conf", adapter)
// Add policy at runtime:
await enforcer.addPolicy("dave", "package", "read")
// Remove policy:
await enforcer.removePolicy("dave", "package", "read")
accesscontrol
accesscontrol — simple RBAC:
Define grants
import { AccessControl } from "accesscontrol"
const ac = new AccessControl()
// Admin:
ac.grant("admin")
.createAny("package")
.readAny("package")
.updateAny("package")
.deleteAny("package")
.createAny("user")
.readAny("user")
.updateAny("user")
.deleteAny("user")
// Editor:
ac.grant("editor")
.createOwn("package")
.readAny("package")
.updateOwn("package") // Own packages only
.deleteOwn("package")
.readAny("comment")
.createOwn("comment")
.updateOwn("comment")
.deleteOwn("comment")
// Viewer (extends editor's read permissions):
ac.grant("viewer")
.readAny("package")
.readAny("comment")
.createOwn("comment")
.updateOwn("comment")
// Role hierarchy:
ac.grant("superadmin").extend("admin")
Check permissions
// Check:
const permission = ac.can("editor").createOwn("package")
console.log(permission.granted) // true
console.log(permission.attributes) // ["*"] (all attributes)
// With attribute filtering:
ac.grant("viewer").readAny("package", ["name", "version", "downloads"])
// Viewers can only see name, version, downloads — not internal fields
const perm = ac.can("viewer").readAny("package")
const filtered = perm.filter(packageData)
// Returns only { name, version, downloads } — strips other fields
Express middleware
function authorize(action: string, resource: string) {
return (req, res, next) => {
const permission = ac.can(req.user.role)[action](resource)
if (!permission.granted) {
return res.status(403).json({ error: "Forbidden" })
}
// Filter response attributes:
req.permission = permission
next()
}
}
app.get("/api/packages", authorize("readAny", "package"), async (req, res) => {
const packages = await PackageService.getAll()
// Filter each package to only allowed attributes:
const filtered = packages.map(pkg => req.permission.filter(pkg))
res.json(filtered)
})
Feature Comparison
| Feature | CASL | Casbin | accesscontrol |
|---|---|---|---|
| RBAC | ✅ | ✅ | ✅ |
| ABAC | ✅ | ✅ | ❌ |
| Isomorphic (browser) | ✅ | ❌ | ❌ |
| Prisma integration | ✅ | ❌ | ❌ |
| React components | ✅ (<Can>) | ❌ | ❌ |
| Policy DSL | ❌ | ✅ | ❌ |
| Database persistence | Via Prisma | ✅ (adapters) | Manual |
| Multi-language | JS/TS only | 15+ languages | JS/TS only |
| Admin UI | ❌ | ✅ | ❌ |
| TypeScript | ✅ First-class | ✅ | ⚠️ |
| Weekly downloads | ~500K | ~200K | ~200K |
Designing an Authorization Model
Before implementing authorization, design your permission model explicitly. The most common mistake is adding authorization as an afterthought — bolting if (user.role !== "admin") return 403 checks onto routes without a coherent model. A good authorization model answers three questions for every operation: who (subject) can do what (action) on which data (resource), and under what conditions (constraints). CASL's ability definition forces this thinking explicitly at setup time. Casbin's model file makes the policy structure visible as configuration separate from business logic. AccessControl's grant notation encodes ownership semantics (Any vs Own) that are easy to overlook when writing ad hoc role checks. Whichever library you choose, document the full permission matrix — which roles have which permissions on which resources — in a way that non-developers can review, because the business requirements for authorization are often defined by product managers and compliance teams rather than engineers.
Production Security Considerations
Authorization libraries are security-critical code and deserve more review than typical utility dependencies. CASL's MongoDB-style conditions ({ authorId: user.id }) must be validated server-side on every mutation — never trust the client to send the correct conditions. The accessibleBy(ability).Package Prisma integration is particularly powerful, but verify that it generates correct SQL WHERE clauses for your specific permission model rather than assuming it handles every edge case. Casbin's enforce() call is synchronous or async depending on the adapter, and failing open (defaulting to true on errors) is a critical vulnerability — always fail closed and log the enforcement failure for investigation.
Performance and Caching Strategies
In high-traffic APIs, authorization checks on every request can become a bottleneck if the policy data must be fetched from a database. Casbin supports adapter-level caching and policy auto-loading on a configurable interval — enable this for production deployments where policies change infrequently. CASL abilities should be built once per request from the session's role/permission data rather than re-queried from the database each time. Cache the serialized ability object in the JWT payload or session store for read operations, but always re-derive abilities for write operations where stale permissions create security risks. The accesscontrol library's synchronous can() checks are naturally fast since all grants are held in memory.
TypeScript Integration
CASL's TypeScript integration is the strongest of the three. Defining type Actions = "create" | "read" | "update" | "delete" and type Subjects = "Package" | "User" enables the TypeScript compiler to catch calls like ability.can("publish", "Package") where "publish" is not a valid action — a compile-time error rather than a silent runtime bug. Casbin's TypeScript SDK provides types for the enforcer API but not for policy structure — the string-based enforce("alice", "package", "delete") call is untyped and can silently fail if the argument order doesn't match the model definition. For accesscontrol, the community @types/accesscontrol package covers the main API but the attribute filtering system is loosely typed, requiring explicit casting at the point where permission.filter(data) returns an any.
Multi-tenancy and Organizational Hierarchy
CASL's multi-tenancy story uses scoped abilities — create separate ability instances per tenant, keyed by the tenant ID from the JWT. Casbin has a dedicated multi-tenancy model template where the tenant is added to the request definition (r = sub, dom, obj, act) enabling policy rules like p, admin, tenant-A, packages, write. This domain-aware RBAC is Casbin's strongest differentiator for SaaS platforms where each organization has independent role hierarchies. The accesscontrol library has no built-in multi-tenancy support — teams typically instantiate separate AccessControl objects per tenant or encode the tenant in the resource name as a prefix.
Migration Between Libraries
Migrating from accesscontrol to CASL is the most common migration path as applications grow beyond simple role-based grants. The conceptual mapping is direct: ac.grant("admin").createAny("package") becomes can("create", "Package") in CASL. The significant difference is that CASL's field-level conditions replace accesscontrol's attribute array filtering — you gain more expressive power ({ authorId: user.id }) but lose the automatic field stripping that permission.filter(data) provides. Teams migrating from Casbin to a JavaScript-native solution typically choose CASL because Casbin's policy-file approach creates friction in TypeScript codebases that want compile-time guarantees rather than runtime policy parsing.
Auditing Permission Changes and Compliance
Enterprise applications often require audit logs of authorization changes — who granted a permission, when it was revoked, and what policy change caused an access control decision. CASL's ability definitions are code-defined, meaning permission changes are tracked through version control — the git history of your ability factory function serves as an audit trail for permission logic changes. For runtime audit logging of access control decisions, wrap CASL's ability.can() calls in a decorator or middleware that logs the subject, action, resource, and decision to an append-only audit log. Casbin's policy persistence in a database enables audit logging at the storage layer — any adaptor write operation (adding or removing a policy rule) can be intercepted and logged. The Casbin audit middleware records every enforce() call result along with the subject, object, and action that were evaluated. For regulated industries (healthcare, finance, government), the combination of Casbin's runtime enforcement logging and policy version history satisfies the most common compliance requirements for access control auditing.
Testing Authorization Logic Comprehensively
Authorization logic is security-critical code that deserves thorough test coverage, yet it is frequently undertested because the tests require scaffolding a user context and request environment. With CASL, test your ability definitions directly by calling ability.can("action", subject) in unit tests without any HTTP layer — create ability instances for different user roles and assert the expected permission outcomes for each role. Use table-driven tests to cover the combinatorial space: each role crossed with each action on each subject type. Casbin's policy engine can be tested with in-memory adapters — load the model and policies programmatically and call enforcer.enforce(sub, obj, act) to verify that the policy produces the expected allow or deny result. Pay particular attention to negative tests: verify that an admin role does NOT have permissions it shouldn't have, rather than only testing that it has the permissions it should. For accesscontrol, test the grant definitions by calling ac.can(role).createAny(resource).granted and verifying the boolean outcome matches your expected permission model before any HTTP requests are made.
When to Use Each
Choose CASL if:
- TypeScript-first project — best type inference for abilities
- Need isomorphic authorization (same rules in Node.js AND React)
- Using Prisma —
accessibleBy()auto-filters database queries - Want ABAC with MongoDB-style conditions (
{ authorId: user.id })
Choose Casbin if:
- Complex authorization requirements (RBAC + ABAC + custom models)
- Multi-language system — same Casbin model across Go, Java, Python, Node.js
- Need a policy admin UI for non-developers to manage permissions
- Want to store policies in a database with hot-reload
Choose accesscontrol if:
- Simple RBAC is sufficient — roles → resources → actions
- Need attribute filtering (only return certain fields based on role)
- Own vs Any distinction is your primary access pattern
- Straightforward, minimal API
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on @casl/ability v6.x, casbin v5.x, and accesscontrol v2.x.
Compare authorization and security packages on PkgPulse →
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.