Honest feature matrix across the six built-in adapters, a decision tree, and answers to the questions that come up when picking or migrating storage
All six built-in adapters pass the same compliance suite, so they agree on every decision your engine makes. What differs is durability, concurrency, which optional methods they implement, and how loudly they fail.
This table is src/adapters/__compliance__/optional-support.ts, which is declared rather than probed and checked against the real prototypes by optional-method-matrix.test.ts.
Optional method
Memory
File
Prisma
Drizzle
Redis
HTTP
getSubjectScopedRoles
yes
yes
yes
yes
yes
yes
updateAssignmentScope
yes
yes
yes
yes
no
no
getSubjectGrantBoundary
no
no
no
yes
no
no
assignRoleMany
no
no
no
yes
no
no
revokeRoleMany
no
no
no
yes
no
no
withClient
no
no
yes
yes
no
no
Three of the six exist on Drizzle alone, because only its schema carries starts_at / expires_at and only it can batch through a transaction.
Notes on the sharp bits:
A missing optional method costs correctness nowhere.updateAssignmentScope, assignRoleMany and revokeRoleMany are optimisations the engine falls back for - revoke-then-assign, and a per-row loop - and runEngineCapabilityCompliance pins the behaviour behind them on all six adapters regardless of what the table says. The exception is withClient: without it engine.withTransaction throws rather than writing outside your transaction, so only Drizzle and Prisma can join one.
IAssignOptions is refused, not dropped. Only Drizzle stores startsAt / expiresAt / attributes. The other five throw and name the option rather than accepting the grant and discarding the expiry, which used to make a break-glass grant permanent while the batch API still reported applied: 1.
Memory and Prisma have no onPolicyError hook. Memory validates its seed through the same guards its write path uses; Prisma has no options object to wire a handler into, so its dropped-row reports go to console.warn.
Decision tree
The first branch that matters is writers, not readers. Many read-only processes are fine against a file, because the engine's LRU absorbs almost all traffic; two processes that both call engine.admin.* against one file will clobber each other. The second is whether you already run an ORM: if you do, the matching adapter is almost always the right answer, because your migrations, backups, and observability already cover that database. Redis earns its place when several instances need the same authorization state with sub-millisecond reads; a custom adapter is the answer when your data already lives somewhere none of the six understand.
Migrating between adapters
Engine, builder, server middleware, and client code are unchanged - only the constructor line and the data move.
Export from the old adapter
Read everything through the adapter interface itself: listPolicies(), listRoles(), and, per subject,
getSubjectRoles(), getSubjectScopedRoles(), and getSubjectAttributes(). There is no listSubjects on the
interface, so you need your own list of subject ids - usually a query against your users table.
Import into the new adapter
Replay the same calls as writes: savePolicy, saveRole, one assignRole(subjectId, roleId, scope?) per grant,
and setSubjectAttributes. Save the roles before the grants - every adapter refuses an assignment naming a role it
does not hold. Saves are upserts and assigns are idempotent everywhere, so the import can be re-run safely.
Verify with the same decisions
Run your authorization tests against both adapters and assert identical results. Adapters that disagree are
usually adapters where one broke the scoped/unscoped split.
Swap the constructor and warm the cache
Change the adapter passed to IamEngine, then call await engine.preload() on boot so the first request does
not pay the cold read.
FAQ
It is the persistence layer and nothing else. It loads and stores policies, roles, role assignments, scoped
assignments, and subject attributes. The engine owns evaluation, policy combination, caching, explain traces,
and decision logic.
Nineteen exist; thirteen are required. The six optional ones are
getSubjectScopedRoles,
updateAssignmentScope,
getSubjectGrantBoundary,
assignRoleMany,
revokeRoleMany and
withClient.
No. It stores everything in process memory. Restarting the process resets policies, roles, assignments, and
attributes, which is why it is limited to tests, demos, and local development.
No. The assignments seed is
Record<string, TRole[]> and every entry it creates is
unscoped. For scoped grants, construct the adapter and then call
assignRole(subjectId, roleId, scope).
Yes. duck-iam needs four storage areas - policies, roles, assignments, and subject attributes - and they can live
alongside the rest of your application schema.
Prisma expects named models and handles JSON columns natively. Drizzle expects you to pass the four tables and the
eq / and
operators explicitly, and its json option chooses between
native JSON columns and stringified text. Drizzle ships pre-built schema modules for Postgres, MySQL, and SQLite;
Prisma ships a single reference schema.prisma snippet. Drizzle is also the only adapter that
implements temporal grants and per-grant attributes.
When several app instances need the same authorization state with sub-millisecond latency. For tens of thousands
of policies, or for audit and history requirements, relational adapters scale better. Many deployments use both:
Redis as the engine adapter for hot reads, Postgres as the audited source of truth, synced by a background job.
They merge, shallowly, per key. Keys absent from the patch survive; keys present are overwritten. The compliance
suite pins this, so it is true of every adapter including custom ones.
Not in the built-in adapters. Each one reads the current bag, spreads the patch over it, and writes the result,
without a transaction, so two concurrent merges to the same subject can lose one of them. If that matters, use a
backend-atomic merge in a custom adapter.
Every adapter rejects it with the same message,
attributes for "<id>" must be a plain object (got string),
before writing anything. Existing attributes are left untouched. Without this guard a string would spread into
per-character attribute keys.
A repeat grant is a no-op on all six. Memory and file check in memory, Redis relies on set semantics, Drizzle uses
onConflictDoNothing, and Prisma reads first and treats the
P2002 a racing writer causes as success. Prisma needs its unique
index replaced by hand for that to hold under concurrency - see the
Prisma adapter page.
It removes every assignment of that role for the subject, scoped and unscoped alike. That is the
cross-adapter contract and the compliance suite asserts it. Pass the scope explicitly to remove only one grant.
Only Drizzle. Its pg, mysql, and sqlite schemas carry
starts_at, expires_at,
and attributes columns, and reads filter out grants outside the
[startsAt, expiresAt) window. The other five throw and name the option they cannot store, rather than
accepting the grant and dropping the expiry.
No. It fetches authorization data over HTTP; the consuming engine still evaluates locally. Use it when you want
shared storage behind a service boundary, not when you want to outsource evaluation.
Yes. The headers option can be a static object or an async
function, which makes it usable with short-lived bearer tokens and per-request auth state.
No. The admin router exposes /policies, /roles, and subject role-assignment endpoints, but
IamHttpAdapter also expects subject-attribute reads (/subjects/:id/attributes) and scoped-role reads
(/subjects/:id/scoped-roles). Treat the router as a starting point and add the remaining endpoints
yourself. The same applies to Hono's bindAdminRouter, Next.js's createAdminHandlers, and
NestJS's createAdminOperations factories.
No. Stored values are persistent. Engine-level caching is the in-process LRU with its
cacheTTL - Redis is the source of truth, not a TTL cache.
Configure AOF or RDB persistence if you do not want data loss on restart.
Use keyPrefix per tenant. Two adapters with different prefixes
cannot read each other's keys. Combine with separate Redis logical databases (SELECT n) for stronger
isolation, and give the Redis invalidator a
tenantId so invalidations are isolated too.
It must keep global and scoped assignments separate. Global assignments belong in
getSubjectRoles(); scope-bound assignments belong in
getSubjectScopedRoles() so the engine merges them only for
matching request scopes.
Global role ids only, deduplicated. Collapsing scoped grants into that list leaks a role granted for one tenant
into every tenant, and the same subject would then decide differently across backends.
Yes. A custom adapter can omit getSubjectScopedRoles and the
engine still works - it sees no scoped grants, with no error and no warning. Declare it
false in the compliance suite's
supports literal while you do, or the first scoped read-back
throws. Add the method and the scoped storage when you need tenant-aware resolution.
Yes, that is an intended adoption path. Engine, builder, and middleware APIs are unchanged; the work is moving
roles, policies, assignments, and subject attributes into the new adapter's storage shape - see
Migrating between adapters above.
Not directly - IConfig.adapter takes exactly one. Write a thin
composite adapter that implements the interface and delegates each method to whichever backend owns that data.
Run runAdapterCompliance(name, factory, { supports }) and
runEngineCapabilityCompliance(name, factory) against it. They are the
same two suites every shipped adapter passes; the factory must return a fresh, empty store on each call, and
supports must name the optional methods you actually implement.
See the
Custom adapter page.