Authorization that
explains itself.

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.

🔍
A named reason on every decision Every allow and deny carries the ID of the rule that fired — or "default" when none matched. No more debugging a silent 403.
🚦
Enforcer modes for safe rollouts Switch to audit mode to observe how a new policy behaves before it enforces. Lockdown denies everything without touching a single rule.
Try it live — runs entirely in the browser.
  1. Pick a preset or change the role, action, and document status.
  2. See the exact rule that matched — not just allow or deny.
  3. Toggle enforcer modes to see how audit and lockdown change the outcome.
npm install @daltonr/authwrite-core
Live Authwrite Engine — Not a Mockup live

Presets

Role

Status

Action

owns

Enforcer mode

ALLOWED
reason
effect
defaulted false
policy documents v1.0.0
169 Tests — and zero runtime dependencies
4 Framework adapters — Express, Fastify, Next.js, Hono
1 Authorization policy shared across every layer

The authorization problem

The status quo

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.

// Which of these caused the 403? No idea. if (!user.roles.includes('editor')) return 403 if (doc.status === 'archived') return 403 if (doc.ownerId !== user.id && !user.isAdmin) return 403

The Authwrite approach

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.

// Now you know exactly which rule fired. // decision.reason → 'archived-blocks-mutation' // decision.allowed → false // decision.durationMs → 0.08

Who is Authwrite for?

🏗️

SaaS teams with growing complexity

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.

🔀

Teams migrating from scattered if-statements

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.

🔏

Compliance-conscious engineering teams

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.

😤

Engineers frustrated with OPA or Casbin

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.

Built for observable authorization

🔍

Every decision has a reason

The reason field on every decision carries the ID of the rule that matched — or "default" when none did. No more silent denials.

📝

Pure TypeScript rules

Rules are plain functions — no DSL to learn, no schema to memorise. Your match function receives the full context and returns a boolean.

🔌

Pluggable observers

Wire up OpenTelemetry, Postgres audit logs, or a custom webhook — all through the same AuthObserver interface. Every decision flows through every observer.

🚦

Enforcer modes

Switch between enforce, audit (permissive + logged), suspended (denied + logged), and lockdown (immediate deny, engine bypassed) without changing a single rule.

🧩

Framework adapters included

Drop-in middleware for Express, Fastify, Next.js, and Hono. The core runs in any environment — Node.js, edge runtimes, or the browser.

🔗

HATEOAS link building

@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.

📦

One policy, every layer

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.

🧪

Testable in isolation

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.

Real-world scenarios

🏢

Multi-tenant SaaS

SaaS · B2B

Isolate tenant data behind org-scoped rules. Combine role and membership checks without repeating logic across every endpoint.

  • org-member-only
  • admin-full-access
  • viewer-read-only
📄

Document collaboration

Productivity · Content

Owners can do everything. Editors can read and write but not delete. Archived documents block all mutations, even for admins.

  • archived-blocks-mutation
  • owner-full-access
  • editor-read-write
🏥

Healthcare field redaction

Healthcare · PHI / HIPAA

Use field-level rules to redact PHI fields from API responses when the requesting user lacks clinical clearance — same policy, different visibility.

  • clinical-staff-full-view
  • admin-redact-phi
  • patient-own-records
🛒

E-commerce back-office

Retail · Operations

Warehouse staff can update fulfillment. Finance can view and export. Only ops leads can issue refunds or cancel orders.

  • finance-view-export
  • warehouse-fulfillment
  • ops-lead-full-control
🏦

Audit-mode rollout

Finance · Risk · Compliance

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.

  • engine.setMode('audit')
  • observer → audit log
  • engine.setMode('enforce')

The full picture in three files

// 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)

Why not OPA, Casbin, or CASL?

OPA

Policy-as-code at scale

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.

Casbin

Mature RBAC/ABAC engine

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.

CASL

Ability-based access control

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?"

Custom

Works — until it doesn't

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 comparison

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.

Documentation

View all documentation →

Ready to stop guessing
why access was denied?

Every denial deserves an explanation. Authwrite makes that the default.

npm install @daltonr/authwrite-core