S
supero.docs
Documentation/Under the hood/Give your AI agent no credentials of its own

Give your AI agent no credentials of its own

An AI agent that answers questions over your data should hold no credential of its own. How the caller's token is forwarded, where enforcement actually lives, and what is still open.

  • ai-agent
  • security
  • rbac
  • llm
  • architecture

Overview

The most dangerous thing you can hand an agent is a service account.

---
Every "ask your data a question" feature starts with the same architectural fork, and the wrong answer is the convenient one.
The convenient answer gives the agent a service account.
It can read everything, so it can answer anything. That demos beautifully and closes deals. Then somebody asks a question whose most accurate answer happens to span two customers, and the agent gives it to them, in a fluent and confident paragraph, because nothing in its position ever made it capable of knowing that it should not.
The other answer is that the agent holds **nothing**.
Our agent has no credential. Every tool call it makes forwards the calling user's own token. A tool invocation arriving with neither a JWT nor an `X-API-Key` does not fall back to anything.
It fails.
That single property is worth more than any amount of prompt engineering about what the agent should decline to do, because prompt instructions are advisory and a missing credential is not.

Where the boundary actually is

This is the part most write-ups get wrong, including ones about systems that are built correctly.
Our agent tier does **not** verify the token's signature. It checks that the thing is structurally decodable, normalises garbage into a `401`, and forwards it. The platform underneath remains the signature authority and rejects a forged token when the call arrives.
So the agent is a **forwarder**, not the authorisation boundary.
That sounds like a weakness written down as a design.
It is the opposite. If the agent tier verified signatures and made its own authorisation decisions, you would have two systems that must agree about permissions forever, across every future change to either of them, and the genuinely interesting bugs in this whole category live in precisely the gap between two such systems that have drifted a little.
One authority, consulted by everything, is fewer places to be wrong.
The corollary is the sentence that belongs in your own design doc: **the agent is not where you enforce access, so do not let anyone add an enforcement decision there.**

Four layers, and only one of them is in the agent

**No credential of its own.** The caller's token, or nothing.
**A tool surface that omits the platform's own furniture.** No `crud_` tool is generated for the ten record types that describe the system itself. The agent's generated surface has no tool that lists `api_key` records, so it cannot be asked for one.
The detail worth copying is that this suppression is **namespace-aware**. A customer application that legitimately declares its own object type called `project` still gets tools for it. The suppression matches on the `schema_namespace` and not on the bare name. A denylist of names alone would have quietly broken every application that used a common word.
**Schema-level read-only.** A type marked `read_only` yields only `search` and `get`.
The agent is structurally incapable of writing to it. Not instructed not to.
**Row and field enforcement, downstream.** This is where the real work happens, and it is the same code path that a direct API call goes through. An agent asking for a total is subject to the same scope clamp, the same hidden-field rules and the same policy the caller would meet with `curl`. That includes the owner filter described in The permission that returned zero rows to everyone.

The guard that raised on the line before it guarded

Analytics queries are scoped by matching against the record's address. If the caller's domain is somehow empty, matching on the *project* segment alone would match records across every domain that happens to use the same project name. Picture several customers who all named a project `analytics`, which is not a stretch. A rollup would span every one of them, and every number on the dashboard would be wrong in a direction nobody questions. Totals that are too large look like growth.
The fail-closed fix is neat: when the domain is empty, inject a predicate that cannot match anything, so the query returns zero rows rather than everyone's rows.
The first version of that guard logged a warning before injecting the predicate. The logging call used a formatting idiom the logger did not accept, so it **raised** — on the line above the one that made the query safe.
A security control that raises on the path it guards is not a control.
It was found on 7 September 2026, by running it against the real logger instead of a mock. The lesson is not "write better logging calls". It is that a fail-closed path needs a test that actually traverses it, because a fail-closed path is by definition the one that never runs in normal operation, and therefore the one your integration tests never touch.

What the aggregation path had to block

Letting a model shape an analytics query means the query language becomes an attack surface. Ours blocks by category rather than by pattern-matching for bad ones.
Operators that write to a collection are rejected outright, and so are the ones that read across collections. From an agent's point of view `$out`, `$merge` and `$lookup` simply do not exist.
`$where` and `$function` are rejected for executing JavaScript server-side. `$regex` is rejected because an unbounded pattern is a denial-of-service vector before it is anything else.
A pipeline is capped at 10 stages and 10,000 results, with a 30 second ceiling and a 1 MB cap on the incoming request, and a safety `$limit` is appended to any pipeline that did not bring one.
Two subtler ones are worth naming.
A pipeline can reference the whole document instead of naming fields, and that slips past a guard which inspects field names. If a pipeline does that, the entity's hidden fields are force-included in the check, so the guard denies rather than shrugging.
And there is an output strip as well as an input guard. A pipeline with no projection returns complete documents, so hidden columns are removed on the way out too. Without that second pass, the analytics endpoint hands back every column the CRUD endpoint carefully removed.
Those two guards once disagreed about how to look up the entity's policy — one used the namespaced name, the other the bare one — so a caller could be refused a grouping on a column and then receive that column in every row of a different query. They now resolve the policy as the union of both key shapes.

The design decision we would make differently

**Confirmation gating should not be a constructor argument.**
The gate that pauses a destructive operation for human approval is a list of patterns supplied when the agent is built, and it defaults to empty. That is the wrong default and the wrong location. A safety gate configured by each caller is a gate that is eventually off somewhere, and the fact that ours is configured everywhere it needs to be today is a property of our diligence rather than of the design.
Default it to on, make it deny-by-default for anything that writes, and require an explicit opt-out that somebody has to type. We are moving it.
The same reasoning applies to anything else in an agent framework where the safe behaviour is the one you get by forgetting to configure it. If you are building this, that is the question worth asking of every option you expose: what happens to the person who does not set it?

Four questions, before you ship an agent

Does the agent hold a credential of its own?
If yes, that is the whole risk, and everything else you build is decoration on top of it.
Is there exactly one authorisation authority, or two that must agree?
Does the agent's tool surface include tools for your platform's own objects, and if you excluded them, did you exclude them by name in a way that also breaks a customer whose object is called `project`?
And does your fail-closed path have a test that runs it? That is the one that got us.
*(Disclosure: I work on Supero. The open items above are current as of 23 September 2026. They are written down because an article recommending an architecture owes you the places where its own implementation of that architecture is incomplete.)*