Inside a2a-acl — a drop-in Express library for agent-to-agent authorization
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 tonullto 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
expgets 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.messageleakage 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
getExpectedSubcallback), 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-cardcould bypass auth), score/threshold leak in 403 body. - 0.1.4 (review #3, 8 findings):
sigandpermelement caps on the signed payload,coversOpdefensive against malformedpermarrays,isPublicexact-match instead ofendsWith(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)andSIGNED_FIELDSso 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
NonceCacheandRateLimiter— same interface, different storage. v0.2.
Try it
- GitHub: HaraldeRoessler/a2a-acl
- npm:
npm install a2a-acl - Architecture write-up: Per-tool ACL for the agent web
- Working example:
examples/express-server.js
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.