ownify.blog

← All posts

Inside a2a-acl — a drop-in Express library for agent-to-agent authorization

30 April 2026 · ownify · #a2a #security #library #opensource #javascript

a2a-acl is the open-source middleware library behind ownify's production A2A gateway. MIT, no runtime dependencies, ~600 lines of Express middleware that you wire to your own storage. This post walks the API, the strict defaults, the hardening history (four external review rounds, 38 findings, zero open CodeQL alerts), and what's deliberately not in scope.

Inside a2a-acl

A few days ago we wrote about the per-tool ACL design that fronts every inbound A2A call to an ownify agent. That post is about the architecture: capabilities instead of trust scores, default-deny, hard-vs-soft enforcement, the order the firewall stages run in.

This post is about the library. We extracted the policy layer of that gateway into a2a-acl — a drop-in Express middleware package that runs in production at ownify.ai and is now available standalone. MIT-licensed, no runtime dependencies, on npm:

npm install a2a-acl

If you're building a service that receives agent-to-agent traffic and you want the same authorization shape — without rolling your own AAE verifier, nonce cache, ACL evaluator, trust gate, sanitiser, depth guard, circuit breaker, rate limiter, and audit logger — this is the library. You bring your storage; the library brings the algorithm.

The eight stages

The chain composes the same eight stages we run in production:

Stage Enforces Failure
verifyAaeMiddleware Cryptographic envelope (Ed25519, replay + revocation + audience + expiry) 401
aclCheckMiddleware (receiver_slug, caller_did, capability) is in your ACL store 403 acl_no_capability_grant
trustScoreGateMiddleware Caller's trust score clears the matched rule's threshold (or your default) 403
sanitiseMiddleware Strips prompt-injection markers from the request body (forwards, audit-only)
depthGuardMiddleware AAE chain hop count ≤ maxHopCount 403
circuitOpenCheckMiddleware Upstream peer slug isn't in cooldown 503
rateLimitMiddleware Per-(caller, peer) requests/min + daily token budget 429
auditMiddleware Fire-and-forget sink for every decision (logs only)

Order matters and is non-trivial. The trust gate runs after the ACL match because the matched ACL row carries an optional threshold_override that lets an operator demand a higher score from one specific peer or lower it for a trusted bilateral pair. Earlier in development we ran trust before ACL and threshold_override was silently dead code — that's the kind of thing baking the chain composition into the library is for. Use firewallChain(config) and you can't accidentally re-introduce that bug.

Bring-your-own-storage

The library doesn't open a database connection, doesn't talk to a key service, doesn't know what your trust algorithm is. Four async callbacks are everything you need to wire:

import express from 'express';
import {
  firewallChain,
  KeyResolver, TrustResolver, RevocationChecker,
  NonceCache, RateLimiter, DailyTokenBudget, CircuitBreaker,
} from 'a2a-acl';

// 1. Key lookup. Return { public_key_b64url, sig_alg: 'Ed25519' } or null.
//    Throw on transient failures — the library retries by not caching.
const keyResolver = new KeyResolver({
  resolve: async (keyId) => db.keys.findOne({ key_id: keyId }),
});

// 2. Trust score. Return a number 0..1 (or { score }), or null for unknown.
const trustResolver = new TrustResolver({
  resolve: async (did) => ({ score: await scoreLookup(did) }),
});

// 3. Revocation. Return true if the envelope's jti has been revoked.
const revocationChecker = new RevocationChecker({
  check: async (jti) => db.revocations.exists({ jti }),
});

// 4. ACL match. Return the rule row, or null if no rule grants.
async function matchAcl({ slug, callerDid, capability }) {
  return db.acl.findOne({ peer_slug: slug, caller_did: callerDid, capability });
}

const app = express();
app.use(express.json());
app.use('/api/a2a/:slug', (req, _res, next) => {
  req.firewall = { slug: req.params.slug };
  next();
});

app.use('/api/a2a/:slug', ...firewallChain({
  keyResolver, revocationChecker, nonceCache: new NonceCache(),
  trustResolver,
  rateLimiter: new RateLimiter({ requestsPerMinute: 5 }),
  tokenBudget: new DailyTokenBudget({ tokensPerDay: 10_000 }),
  circuitBreaker: new CircuitBreaker(),
  matchAcl,
  defaultThreshold: 0.7,
  maxHopCount: 3,
  expectedAud: 'a2a-ingress',
  basePath: '/api/a2a/:slug',
  sink: (row) => db.audit.insert(row),
}));

app.post('/api/a2a/:slug/message', (req, res) => {
  // The chain accepted. req.firewall.callerDid, .aae, .aclRule, .trustScore
  // are all populated. Forward to your agent runtime.
});

That's the whole shape. A complete, runnable example is in examples/express-server.js in the repo (npm run example).

Strict defaults, on purpose

A library that ships permissive defaults gets cargo-culted into permissive deployments. So the defaults are the strictest setting that doesn't break the common case:

  • Audience required by default (expectedAud: 'a2a-ingress'). Set it to null to opt out — explicit, not silent.
  • Expiry required on every envelope. No "long-lived tokens" path.
  • Maximum envelope lifetime: 5 minutes. A signer who issues with a longer exp gets rejected before signature verification.
  • Ed25519 only. No none, no RS256, no algorithm-confusion class.
  • Fail-closed nonce cache. If the in-memory replay cache is full, new envelopes are rejected rather than admitted with no replay protection.
  • Bounded everything. Rate-limit buckets, token-budget buckets, resolver caches, in-flight maps, circuit-breaker peers — all capped. An attacker flooding with unique caller DIDs can't OOM the gateway.
  • Opaque error bodies. 401/403/503 don't echo err.message, don't leak the trust score, don't tell the attacker how close they are to passing. Server logs the detail; client gets the bare result.
  • Query string stripped from audit by default. Tokens and session IDs in URLs end up in audit logs only if you opt in.

These are the kinds of things that would feel pedantic in a hand-rolled gateway — and that absolutely will get cut during "let's just get it working" pressure. Putting them in the library means the strict version is the cheap version.

How we hardened it

We shipped 0.1.0 internal-only, then ran four external review rounds before publishing 0.1.4 to npm. Total of 38 distinct findings, all fixed. Highlights from each round:

  • 0.1.1 (internal hardening): aud required, exp required, fail-closed nonce, type checks at every boundary.
  • 0.1.2 (review #1, 10 findings): err.message leakage in HTTP bodies, unbounded buckets across rate-limit/token-budget/resolvers, __proto__ in JSON canonicalisation, capability segment validation, query string in audit logs, sink error log level.
  • 0.1.3 (review #2, 14 findings): cross-peer replay (envelope captured for peer A replayed against peer B — fixed by getExpectedSub callback), NaN bypasses across the trust gate and depth guard, content-length poisoning in the token estimator, length caps on every signed-payload string, public-path-under-mount confusion (a path ending in /agent-card could bypass auth), score/threshold leak in 403 body.
  • 0.1.4 (review #3, 8 findings): sig and perm element caps on the signed payload, coversOp defensive against malformed perm arrays, isPublic exact-match instead of endsWith (which had the same shape as the public-path bug from 0.1.3), prototype-key skip in sanitiser, distinct revocation-failure reason for operator observability, numeric parameter validation at construct time so misconfigured thresholds fail loudly rather than silently bypass.

Detail in CHANGELOG.md. After each round we ran the full test suite (90 tests, all passing) plus GitHub's CodeQL — currently zero open alerts, three historic alerts dismissed as false positives in test scaffolding.

What's deliberately not in this library

  • Storage. Your DB schema, your tables, your queries. We're not shipping a Postgres adapter or a Redis adapter. The four callbacks above are the contract.
  • A specific trust algorithm. ownify uses MolTrust (DIDs anchored on Base L2 with a public reputation feed). The library doesn't care — return a number 0..1 from your TrustResolver, the gate compares it.
  • The signing side. This library is for the receiving gateway. Outbound envelope signing belongs on the caller's control plane, with their key material. We export signablePayload(env) and SIGNED_FIELDS so a signer in a different language can produce bit-identical canonical bytes — but the signing logic itself is yours.
  • Framework adapters beyond Express. Fastify and Hono are reasonable next bumps. The core (resolvers, sanitiser, rate-limit, AAE parse/verify, capability schema) is framework-agnostic — the middleware layer is the only Express-specific code, and it's small. v0.2 territory if there's demand.
  • Multi-replica state. The nonce cache and rate-limit buckets are in-process. For HA deployments running multiple replicas, you'd want a Redis-backed implementation of NonceCache and RateLimiter — same interface, different storage. v0.2.

Try it

If you find a bug, open an issue. If you've integrated it and want something adapted to your stack — Fastify adapter, Redis-backed NonceCache, a different signature scheme — that's the kind of input that drives the v0.2 roadmap.

The agent web only works if its identity-and-access layer is something operators trust enough to build on. We're trying to make that layer something you can npm install.

← More posts · ownify home →