Most authorization libraries tell you what they decided. Authwrite tells you why. Every decision carries the rule that matched, the time it took, and whether an enforcer mode override was applied.
"default" when none matched. No more debugging a silent 403.
Authorization starts as a few if statements. Then a role check. Then an ownership check.
Then a status check. Then something special for admins. Six months later, nobody can explain
why a user lost access — and debugging means reading through conditionals scattered across a dozen route handlers.
A 403 fires. Your support team files a ticket. Your engineer opens four files and still isn't sure. You add a log line and redeploy to find out.
Define your rules once as a policy object — named, prioritised, and independently testable. Every evaluation returns not just allowed or denied, but the ID of the rule that decided, how long it took, and whether an enforcer mode changed the outcome.
Authorization becomes observable data. You can log it, trace it with OpenTelemetry, and explain any denial — to your support team, your auditors, or your users — in one line.
Your permissions used to be "is the user logged in?". Now you have roles, plans, org membership, and resource ownership. Authwrite keeps that complexity in one place.
Your auth logic is spread across 20 route handlers. Authwrite can be introduced without breaking anything — start in audit mode, migrate rules one route at a time, then harden enforcement once you're confident.
SOC 2, HIPAA, ISO 27001 — auditors want evidence that access decisions are logged and traceable. Authwrite's observer pattern gives you that out of the box.
Rego has a steep learning curve and needs a sidecar. Casbin's PERM model adds a new DSL. If you want authorization in TypeScript without learning a new language, this is it.
The reason field on every decision carries the ID of the rule that matched — or "default" when none did. No more silent denials.
Rules are plain functions — no DSL to learn, no schema to memorise. Your match function receives the full context and returns a boolean.
Wire up OpenTelemetry, Postgres audit logs, or a custom webhook — all through the same AuthObserver interface. Every decision flows through every observer.
Switch between enforce, audit (permissive + logged), suspended (denied + logged), and lockdown (immediate deny, engine bypassed) without changing a single rule.
Drop-in middleware for Express, Fastify, Next.js, and Hono. The core runs in any environment — Node.js, edge runtimes, or the browser.
@daltonr/authwrite-hateoas builds permission-aware hypermedia links. Only actions the subject is permitted to take appear in the response — no client-side filtering needed.
Package your policy as an independent module. Import the same rules into your API, background workers, and UI — one source of truth, no drift between layers.
Your policy is a plain TypeScript object. Test every rule with a simple function call — no HTTP server, no middleware stack, no framework to spin up.
Isolate tenant data behind org-scoped rules. Combine role and membership checks without repeating logic across every endpoint.
Owners can do everything. Editors can read and write but not delete. Archived documents block all mutations, even for admins.
Use field-level rules to redact PHI fields from API responses when the requesting user lacks clinical clearance — same policy, different visibility.
Warehouse staff can update fulfillment. Finance can view and export. Only ops leads can issue refunds or cancel orders.
New policy ready but not yet enforced? Enable audit mode. Every decision is logged as if enforced — with no real-world impact — until you're confident.
// policy.ts
import { createAuthEngine } from '@daltonr/authwrite-core'
const engine = createAuthEngine({
policy: {
id: 'documents',
version: '1.0.0',
defaultEffect: 'deny',
rules: [
{
id: 'archived-blocks-mutation',
priority: 20,
match: ({ resource }) => resource?.status === 'archived',
deny: ['write', 'delete'],
},
{
id: 'admin-full-access',
priority: 10,
match: ({ subject }) => subject.roles.includes('admin'),
allow: ['*'],
},
{
id: 'owner-full-access',
priority: 5,
match: ({ subject, resource }) =>
!!resource && resource.ownerId === subject.id,
allow: ['*'],
},
],
},
})
// check.ts
const decision = await engine.evaluate({
subject: { id: 'user-1', roles: ['editor'] },
resource: { type: 'document', id: 'doc-42',
ownerId: 'other-user', status: 'archived' },
action: 'write',
})
// decision →
// {
// allowed: false,
// reason: 'archived-blocks-mutation',
// effect: 'deny',
// defaulted: false,
// policy: 'documents',
// durationMs: 0.12
// }
if (!decision.allowed) {
logger.warn('access denied', { reason: decision.reason })
}
// middleware.ts — bind engine once, use per route
import { createExpressAuth } from '@daltonr/authwrite-express'
const auth = createExpressAuth({
engine,
subject: (req) => ({ id: req.user.id, roles: req.user.roles }),
resource: async (req) => db.documents.get(req.params.id),
onDeny: (req, res, decision) => {
res.status(403).json({
error: 'forbidden',
reason: decision.reason, // 'archived-blocks-mutation'
})
},
})
app.get('/documents/:id', auth('read'), readHandler)
app.put('/documents/:id', auth('write'), updateHandler)
app.delete('/documents/:id', auth('delete'), deleteHandler)
Powerful, but requires learning Rego, deploying a sidecar or Wasm bundle, and wiring up decision-reason reporting yourself. Too much overhead for a TypeScript backend.
Battle-tested with a large ecosystem, but the PERM model is a DSL you have to learn before writing your first rule. Doesn't feel native in TypeScript.
Elegant subject-ability model, but no named decision reasons, no enforcer modes, no observer pattern. CASL answers "can you?" — Authwrite answers "can you, and why?"
Starts simple, grows into 600 lines of if-statements with no test coverage and no audit trail. Authwrite is what custom middleware grows into when the complexity needs a structure.
| Feature | Authwrite | CASL | Casbin | OPA | Custom middleware |
|---|---|---|---|---|---|
| Named decision reason | ✓rule ID on every response | ✗ | ✗ | partialmanual in Rego | manual |
| Rules in pure TypeScript | ✓plain functions, full IDE support | ✓builder DSL | ✗PERM model / policy file | ✗Rego DSL | ✓ |
| Enforcer modes (audit / lockdown) | ✓enforce · audit · suspended · lockdown | ✗ | ✗ | ✗ | manual |
| Pluggable observer pattern | ✓OTel, Postgres, Redis, custom | ✗ | ✗ | partialdecision logs via plugin | manual |
| Rule priority & conflict resolution | ✓numeric priority per rule | partialinverted / first-match | ✓effect policy | ✓explicit in Rego | manual |
| Zero runtime dependencies | ✓ | ✓ | ✗ | ✗Go binary / Wasm | ✓ |
| Runs in the browser | ✓ | ✓ | ✗ | partialWasm build only | ✓ |
| Framework adapters included | ✓Express · Fastify · Next.js · Hono | partialcommunity packages | partialcommunity packages | ✗ | ✓you write them |
| Hot-reload policy from file | ✓loader-yaml with fs.watch | ✗ | ✓ | ✓ | ✗ |
| Decision duration tracked | ✓durationMs on every decision | ✗ | ✗ | ✓via metrics API | manual |
✓ supported · ✗ not supported · partial = possible but requires extra work. Comparison reflects typical usage; individual projects may vary.
Installation, core concepts, and your first policy — step by step.
13-chapter narrative guide from the engine model through observers, loaders, testing, policy composition, and authorization anti-patterns.
Full reference for createAuthEngine, evaluatePolicy, fromLoader, intersect, union, firstMatch, and all core types.
Express, Fastify, Next.js, and Hono middleware — configuration, resolver patterns, and error handling.
Every denial deserves an explanation. Authwrite makes that the default.
npm install @daltonr/authwrite-core