Skip to Content
AuthorizationSecurity Contract

Unreleased changes

These pages document main, which has changes 0.1.2 does not. npm install @evanion/acl gives you 0.1.2, documented here. Values on these pages are produced by running the current source on every build.

The Security Contract

Authorization is only as strong as the environment in which it is enforced. While @evanion/acl provides a robust engine for making decisions, it is a tool, not a complete security system. To use it securely, you must understand the boundary between authoritative and advisory decisions.

A decision is authoritative only when it is made in a trusted environment (like a server) and is immediately used to guard a write or a sensitive read. In all other cases—including every check made in a browser—the decision is advisory, meaning it is a hint to the UI and cannot be trusted as a security boundary.

The Trust Chain: Every Layer Decides for Itself

A common architectural failure is the “Trusted Gateway” fallacy—the belief that if a gateway or BFF (Backend-for-Frontend) has already authorized a request, the downstream service can trust that decision.

In a secure system, every layer must evaluate the request for itself. A caller can bypass your gateway and reach your service directly; therefore, the service’s own decision is the only one that matters.

The multi-layer trust chain. The browser provides a UI hint, the gateway provides a routing hint, but only the final service evaluation guards the actual data mutation.

Because the ACL matrix is a frozen object, these repeated evaluations are extremely cheap local function calls that do not require network overhead.

Transparency and the Public Matrix

The ACL matrix is designed to be transparent. When shipped to a client for UI toggles, the entire document—including role strings, field names, and state machines—is visible to anyone who opens the browser’s developer tools.

Security must not rely on the secrecy of the matrix.

  • Avoid Sensitive Naming: Do not name actions or roles in a way that discloses internal secrets (e.g., avoid bypass-internal-audit).
  • Externalize Secrets: Keep server-only predicates and sensitive business logic outside the matrix, in the application layer.

Subject Authenticity (The Confused Deputy)

The can(subject, …) function is a pure evaluator; it authorizes the subject bag it is handed. It has no way of knowing if that bag is authentic.

If you derive the subject from an untrusted source—such as a request header, a query parameter, or a client-posted field—you create a “Confused Deputy” vulnerability where an attacker can claim any identity.

The Fix: Always resolve the subject from a verified, server-side session or a cryptographically signed token before passing it to the ACL engine.

Complete Mediation

@evanion/acl does not automatically guard your application. It is a library, not a framework. Every single entry point into your data must be explicitly guarded.

Forgotten paths—such as a new API route, a background job, or an admin script—remain unguarded until you explicitly call can. While utilities like authorize(subject) in middleware can simplify the process, the responsibility for covering every path remains with the developer.

Time-of-Check to Time-of-Use (TOCTOU)

A decision is a snapshot of the state provided to it. Between the moment can returns true and the moment the write hits the database, the state of the world can change:

  • The object may change owners.
  • The subject may lose a required role.
  • A time-based window may close.

To close this gap, you should either re-read the object and re-check the permission inside the database transaction or use a conditional write predicate that fails if the state has shifted.

Time Windows and Clock Trust

The now parameter allows you to evaluate time-based boundaries (before/after).

now ValueEngine BehaviorResult
OmittedUses the process wall clock.Evaluated against the current server time.
Supplied InstantUses the provided value.Evaluated against the specific instant.
Browser Wall ClockUses the client’s clock.Dangerous: A user can set their system clock back to “re-open” a closed window.
Unparseable / InvalidCannot determine time.Returns { allowed: false, reason: 'unusable-clock' } from every permission that needs the clock to decide.
import { } from '@evanion/acl'; // Preorders for a new game open at one instant. const = < { : string }, { : { : string } }, { : 'place' } >() .('preorder', () => .('place', .('now', '2026-10-01T09:00:00Z')) .('preorders-open'), ) .(); const = { : 'customer-41' }; const = (: string | number) => .(, 'preorder', 'place', , ); ('2026-10-02T12:00:00Z').; // -> true (.('2026-09-30T12:00:00Z')).; // -> 'no-rule-matched' // A header that was never a date. The rule that compared the clock is named. const = ('next tuesday'); .; // -> 'unusable-clock' .; // -> 'preorders-open' 'missing' in ; // -> false

Rule: Never trust a now value sent from a client payload. Always resolve the current time on the server.

Avoiding Self-Authorizing Conditions

A security breach occurs when a subject can modify a field that is then used to grant them more permissions. This is known as a “self-authorizing condition.”

For example, if a rule grants admin access to anyone where object.is_admin === true, and the subject has a write permission that allows them to set is_admin to true, they can escalate their own privileges.

The Fix: Ensure that no action a subject holds can write to a field that is used as a key for a read rule. Key your rules on immutable fields (e.g., created_at) or fields that only the server can modify.

import { , } from '@evanion/acl'; type = { : string; : string[] }; type = { : string; : string; : string }; const = < , { : }, { : 'read' | 'share' } >() .('wishlist', () => .('read', .('object.sharedWith', 'subject.id')) // the subject writes it .('read', .('object.ownerId', 'subject.id')) // set at creation .('share', .('subject.roles', 'customer')) .(['sharedWith']), ) .(); const = { : 'customer-92', : ['customer'] }; const = { : 'customer-41', : 'customer-7', : 'Birthday games', }; .(, 'wishlist', 'read', ).; // -> false // The share dialog submits both fields, and only `sharedWith` is written. const = { : 'customer-92', : 'customer-92' }; const = .( , 'wishlist', 'share', , 'write', , ); const = { ..., ...(, ) }; .; // -> 'customer-41' .(, 'wishlist', 'read', ).; // -> true

Matrix Freshness and Revocation

When a policy is updated on the server, clients holding a cached copy of the matrix will continue to grant permissions based on the old version until they refetch.

  • The Version Check: revalidate compares the held version with the served version. If they differ, the evaluator is rebuilt.
  • The Freshness Budget: maxStale forces a refetch after a certain period, regardless of whether the version has changed.

While these mechanisms reduce the window of inconsistency, they do not eliminate it. Because the server always evaluates its own matrix on every request, a revoked permission is enforced immediately at the API boundary, even if the client’s UI still shows the control.

Where to go next

  • Attack Classes: See the detailed mapping of how the library neutralizes specific CVEs.
  • Field Permissions: Learn how pickAllowedFields prevents mass assignment.
  • Writing Rules: Best practices for authoring secure and maintainable policies.
Last updated on