MQTT.Agent - open protocol for AI agents

Back to blog
ACL Security

Why We Added {$self} to ACL Rules

How ACL v2's identity-bound topic patterns kill per-user rule explosion, what we found about wildcard SUBACK along the way, and what v2.2 added on top.

May 6, 2026
4 min read
By CloudSignal Team

The rule explosion problem

ACL v1 was a flat list. Each rule named a topic and an action. If you had ten agents that each needed to publish to their own card topic, you wrote ten rules. If you had a thousand, you wrote a thousand, or you over-granted with a wildcard and accepted that any agent could publish to any other agent’s topic.

We started seeing this happen for real when the first multi-agent deployments rolled in. A customer with a hundred agents had a hundred rules saying the same thing: “agent X can publish to agents/X/card”. The rules were copies of each other. The policy was 14 KB of repetition.

We could not call that a security model. It was a list of substitutions a human had to maintain.

What {$self} does

In ACL v2.1, a rule can refer to the connecting identity inline:

{
  "topic": "agents/{$self}/card",
  "action": "pub+sub",
  "binding": "agent_id"
}

That one rule covers every authenticated identity. When agent “scout” connects, {$self} resolves to scout’s agent_id claim and the matcher evaluates agents/scout/card. Agent “ranger” gets agents/ranger/card from the same rule. The topic pattern is identity-bound at evaluation time.

The binding field is what makes the substitution unambiguous. We made it load-bearing on purpose. If you write {$self} in a topic, you must tell the matcher which claim it resolves to. There is no default. Forgetting binding is a schema error, not a silent grant.

Sanitization, not trust

Identity claims arrive from outside the broker hooks. A claim value containing /, +, #, or a null byte could be used to escape the topic segment the rule pins it to. The matcher rejects substitution on those characters and logs a warning. The rule does not match. There is no fallback to a looser interpretation.

{$self} also has to occupy a complete topic segment. A pattern like prefix-{$self}-suffix is rejected at schema time, so the matcher never sees a half-resolved topic.

The wildcard bug we found on the way

While building v2.1 we tripped over an existing issue we had not seen before: wildcard subscribes were being granted at the protocol level and then silently dropping every message. SUBACK came back with QoS 1. Zero deliveries.

The cause was on our side. The Lua auth hook returned a single boolean for the whole SUBSCRIBE packet. When one filter matched, every filter in the packet was treated as granted, including ones that should have been denied or that the matcher could not evaluate. Per-filter SUBACK was not flowing through.

We refactored the hook to return one verdict per filter. A denied filter now returns 0x87 (Not authorized) in MQTT 5.0 and 0x80 in 3.1.1, the same as if you had subscribed to a single denied topic. Operators can see exactly which filter failed and why in acl_denials.

What v2.2 adds

The next gap was allow-lists. binding: "agent_id" matches any identity that has an agent_id claim. It cannot scope a rule to “only these specific agents”. The old workaround was require_grant: true and one grant row per identity, which conflates static policy with dynamic per-session grants.

v2.2 added binding_values:

{
  "topic": "control/{$self}",
  "action": "pub",
  "binding": "agent_id",
  "binding_values": ["worker", "supervisor"]
}

Static allow-list. The rule fires only if the identity’s claim is in the list. Dynamic grants stay in the grants table where they belong.

What’s next

The {$<name>} namespace is reserved. We will add other built-ins as customers ask for them. The CLI exposes the full workflow today through cloudsignal acl validate and cloudsignal user claims set, so the same edits that used to need a dashboard click and a manual policy save now fit in two commands you can put in CI.

Related articles