{"slug": "your-authz-checks-the-caller-the-model-picked-the-tenant", "title": "Your Authz Checks the Caller. The Model Picked the Tenant.", "summary": "A developer demonstrated that AI agents can leak cross-tenant data when authorization checks verify the caller but ignore model-authored resource selectors like tenant_id. In tests, 4 of 5 selectors returned another tenant's rows without a provenance gate, and 0 of 5 did with it. The fix is a pre-execution provenance gate that refuses model-written selectors before any read.", "body_md": "A confused deputy in an AI agent is not a broken authorization check. It is an authz check aimed at the wrong operand: it verifies the caller, never the model-authored `tenant_id`\n\nselecting the resource. A pre-execution provenance gate refuses model-authored selectors before any read. Without it, 4 of 5 selectors returned another tenant's rows; with it, 0 of 5 did.\n\nYour agent is authorized to read invoices. This morning it read a different company's invoices, and every authorization check returned yes.\n\nNobody bypassed the gate. The caller was who it claimed to be, the token was valid, the role allowed the tool. The gate answered the exact question it was built to answer, correctly. The leak lived in an argument the gate never looked at: the `tenant_id`\n\nthat selects which company's rows come back. And that argument was written by the model.\n\n**In short:**\n\n`tenant_id`\n\n, `account_id`\n\n, `project_id`\n\n), an authorized caller can reach another tenant's data. The identity check passes the whole time.`tenant_id`\n\nthat came from the authenticated session is fine. The same value, if the model wrote it, is not, because the model picking the right tenant once is luck, not authorization.\n\nAI disclosure:I wrote`scope_provenance_gate.py`\n\nwith an AI assistant and ran it myself, three times, on Python 3.13.5, standard library only, no network, no keys. Every output block below is pasted from that run. The STDOUT is byte-for-byte identical across the three runs; its sha256 is`63adbe8ebe17e873cbf7dbdf24faed392f20a3205d50579aec1705cf3c7841cb`\n\nand`bash run_all.sh`\n\nreproduces it. The fixture is synthetic and calibrated to nothing: the tenants, invoices and sessions are invented to isolate one mechanism. bot2 is a new project. It has no production fleet and no incident to sell you. This post demonstrates how a bug is reachable, not how often it happens in the wild. The one verbatim external quote (the definition of confused deputy) is attributed and linked; the practitioner posts I reference are their words, and I link the primary sources.\n\nHere is the shape of a normal agent tool call. The agent has a tool, `read_account_rows(account_id, limit)`\n\n. A request comes in on an authenticated session. Middleware checks whether this caller, in this role, may invoke this tool. It may. The tool runs. Rows come back.\n\nNow look at where `account_id`\n\ncame from. In an LLM agent the tool-call arguments are assembled from two very different sources. Some fields the runtime injects: the session, the auth context, anything you copy in from the request you already trusted. The rest the model fills, from its plan. `limit`\n\nis a fine thing for the model to choose. `account_id`\n\ndecides whose data you return. If the model writes that field, then the argument that selects the resource is authored by the least trusted component in the system, and the authorization layer never reads it.\n\nThat is a textbook confused deputy. The [Wikipedia article on the confused deputy problem](https://en.wikipedia.org/wiki/Confused_deputy_problem) defines it in one sentence:\n\n\"In information security, a confused deputy is a computer program that is tricked by another program (with fewer privileges or less rights) into misusing its authority on the system.\"\n\nThe term is Norm Hardy's, from his 1988 ACM SIGOPS paper of the same name. The deputy here is your authz middleware. It holds real authority (it can read any account) and it is tricked into using that authority on a target chosen by the model, because it checks the caller and not the selector. The privilege gap is exact: the model has no standing to read account B, the middleware does, and the middleware acts on the model's choice.\n\nSo here is the claim, stated so you can break it: **an authorization layer that verifies the caller and reads the resource-selecting argument from the model's output can return a resource the caller was never scoped to.** The fix does not need a smarter authz check on identity. It needs a check on a different operand: the provenance of the selector. Show me a model-authored `account_id`\n\nthat reaches data through the gate below, or a session-derived one the gate refuses, and the tool is broken and the claim with it.\n\nThe fixture is three accounts and two sessions. `acct_apex`\n\nand `acct_ceres`\n\nand `acct_borealis`\n\n, each with one or two invoice rows. Two authenticated sessions: `S1`\n\n, scoped to `acct_apex`\n\nonly; `S2`\n\n, a shared-ops user scoped to both `acct_apex`\n\nand `acct_ceres`\n\n. Nobody is scoped to `acct_borealis`\n\nhere, which makes it the clean victim.\n\n```\nBLOCK 1 -- the world (synthetic, calibrated to nothing)\n  acct_apex: rows [apex-inv-2201,apex-inv-2202]\n  acct_borealis: rows [bor-inv-5501,bor-inv-5502]\n  acct_ceres: rows [cer-inv-8801]\n  session S1: identity=user_apex_ops role=reader authorized_accounts=['acct_apex']\n  session S2: identity=user_shared_ops role=reader authorized_accounts=['acct_apex', 'acct_ceres']\n```\n\nThe authorization middleware is the ordinary kind. It answers one question and it answers it right:\n\n``` python\ndef authz_allow_caller(session, tool):\n    \"\"\"Answers exactly one question: may this caller invoke this tool?\n    It never sees account_id.\"\"\"\n    return tool in ROLE_TOOLS.get(session.role, set())\n```\n\nAnd the tool trusts that the middleware already did its job, so it does no ownership check of its own:\n\n``` python\ndef run_tool(tool, values):\n    if tool == \"read_account_rows\":\n        account_id = values[\"account_id\"]\n        limit = values[\"limit\"]\n        return list(DB.get(account_id, [])[:limit])\n    raise ValueError(\"unknown tool\")\n```\n\nNeither layer checks who is allowed to read `account_id`\n\n. That is the whole bug, and it is boring, which is why it ships.\n\nEach argument in the model carries a provenance tag: `session`\n\nif the runtime injected it from the authenticated context, `model`\n\nif it came out of the plan. This is not something I invented for the demo. Your runtime already knows which fields it injected and which the model filled, because you wrote the code that assembles the call. The tag just names a fact you are throwing away.\n\nRun the eight calls through the current path: identity check, then the tool. No scope check anywhere.\n\n```\nBLOCK 2 -- every enumerated call, WITHOUT the gate\n  call sess selector(account_id)   prov    id_ok foreign rows_returned\n  C1   S1   acct_apex              session True  False   apex-inv-2201,apex-inv-2202\n  C2   S1   acct_borealis          model   True  True    bor-inv-5501,bor-inv-5502\n  C3   S1   acct_borealis          model   True  True    bor-inv-5501,bor-inv-5502\n  C4   S1   acct_apex              model   True  False   apex-inv-2201,apex-inv-2202\n  C5   S1   acct_ceres             model   True  True    cer-inv-8801\n  C6   S2   acct_ceres             session True  False   cer-inv-8801\n  C7   S2   acct_borealis          model   True  True    bor-inv-5501,bor-inv-5502\n  C8   S1   acct_apex              session True  False   apex-inv-2201,apex-inv-2202\n\n  Cross-tenant reads achieved without the gate: 4 -> C2,C3,C5,C7\n```\n\nRead the `id_ok`\n\ncolumn. It is `True`\n\non every row, including the four that leaked. The identity check never failed. C2 and C3 are `S1`\n\n, scoped to apex, walking out with `bor-inv-5501`\n\nand `bor-inv-5502`\n\n, borealis rows, because the model wrote `account_id = \"acct_borealis\"`\n\nand the middleware only ever asked whether `S1`\n\nmay call the tool. C5 is `S1`\n\nreading ceres. C7 is `S2`\n\nreading borealis, an account it was never scoped to.\n\nThat is the confused deputy in one screen. The gate the system trusts did not break. It answered \"may this caller use this tool?\" with a correct yes, while a different operand it never inspected decided the answer to a question nobody asked: \"may this caller read this account?\"\n\nThe fix keys on the one property that separates C1 from C4. Look at those two rows again: same session `S1`\n\n, same selector value `acct_apex`\n\n, same rows on disk. C1 is safe and C4 is not, and the only difference between them is who wrote the `account_id`\n\n. C1's came from the session. C4's came from the model. The value is identical. The provenance is not.\n\nSo the rule is provenance, and only provenance:\n\n``` python\ndef scope_provenance_gate(call):\n    \"\"\"Looks ONLY at the source of each resource-selecting argument,\n    never at its value. Fails closed on unknown tools or params.\"\"\"\n    schema = TOOL_SCHEMA.get(call.tool)\n    if schema is None:\n        return (False, \"DENY: unknown tool, no schema (fail-closed)\")\n    for param, arg in call.args.items():\n        pspec = schema[\"params\"].get(param)\n        if pspec is None:\n            return (False, f\"DENY: undeclared param '{param}' (fail-closed)\")\n        if pspec[\"resource_selecting\"] and arg.provenance != \"session\":\n            return (False, f\"DENY: resource-selecting arg '{param}' is \"\n                           f\"{arg.provenance}-authored, must be session-derived\")\n    return (True, \"ALLOW: all resource-selecting args are session-derived\")\n```\n\nA tiny schema declares which parameter selects a resource (`account_id`\n\nyes, `limit`\n\nno). The gate runs before authz and before the database. Same eight calls:\n\n```\nBLOCK 3 -- the same calls, WITH the provenance gate\n  call selector         prov    verdict rows / reason\n  C1   acct_apex        session ALLOW   rows [apex-inv-2201,apex-inv-2202]\n  C2   acct_borealis    model   DENY    DENY: resource-selecting arg 'account_id' is model-authored, must be session-derived\n  C3   acct_borealis    model   DENY    DENY: resource-selecting arg 'account_id' is model-authored, must be session-derived\n  C4   acct_apex        model   DENY    DENY: resource-selecting arg 'account_id' is model-authored, must be session-derived\n  C5   acct_ceres       model   DENY    DENY: resource-selecting arg 'account_id' is model-authored, must be session-derived\n  C6   acct_ceres       session ALLOW   rows [cer-inv-8801]\n  C7   acct_borealis    model   DENY    DENY: resource-selecting arg 'account_id' is model-authored, must be session-derived\n  C8   acct_apex        session ALLOW   rows [apex-inv-2201,apex-inv-2202]\n```\n\nC4 is the row I want you to sit with. Its value was harmless. `acct_apex`\n\nis exactly what `S1`\n\nis allowed to read, and the model happened to name it correctly. The gate denies it anyway. That is not the gate being dumb. It is the gate refusing to grade the model on whether it guessed right this time, because a component that is allowed to pick the tenant when it guesses right is allowed to pick the tenant, full stop. If you want apex read, the selector comes from the session. The model does not get partial credit for coincidence.\n\nEvery number I am about to state is a count of the eight rows above. No sample size, no percentage over an invented denominator, no averaging. Count them yourself.\n\n```\nBLOCK 4 -- the counts (recount them from BLOCK 2 and BLOCK 3)\n  K total enumerated calls                          : 8\n  resource selector is session-derived (S)          : 3  -> C1,C6,C8\n  resource selector is model-authored (M)           : 5  -> C2,C3,C4,C5,C7\n  of M, selector points OUTSIDE caller's set        : 4  -> C2,C3,C5,C7\n  of M, selector happens to name caller's own acct  : 1  -> C4\n\n  WITHOUT gate: model-authored selectors that\n    returned another tenant's rows                  : 4 of 5  -> C2,C3,C5,C7\n  WITH gate: model-authored selectors that\n    reached ANY row                                 : 0 of 5  -> (none)\n  WITH gate: session-derived selectors that still\n    returned the caller's authorized rows           : 3 of 3  -> C1,C6,C8\n  WITH gate: session-derived selectors denied        : 0 of 3  -> (none)\n```\n\nThree facts, each a count and not an estimate:\n\nThe 4-of-5 and 0-of-5 are not measurements of a rate that could come out differently on a bigger fixture. They are properties of the construction. Without the gate, a model-authored selector pointing outside the caller's set will always return foreign rows, because identity-only authz always passes and the tool always reads whatever `account_id`\n\nit is handed. With the gate, a model-authored resource selector is always denied, because that is the rule. Grow the fixture to eighty calls or eight hundred and the two structural facts do not move. Only the sizes of the sets do.\n\nA gate that only ever says DENY would pass the leak test and be worthless. So the tool ships four falsifiers, each of which fails loudly if the gate is the wrong shape:\n\n```\nBLOCK 6 -- falsifiers (each could fail if the gate were wrong)\n  [PASS] F1 gate does not break legitimate session-derived scope\n         3/3 session-derived calls served, 0 denied. A deny-all gate would fail this.\n  [PASS] F2 classifier reads the source of the arg, not its value\n         C1 and C4 both select 'acct_apex'; C1 (session) ALLOW, C4 (model) DENY. A value-based check (account_id == self) would ALLOW C4 and miss the rule.\n  [PASS] F3 session-derived scope leaks nothing even without the gate\n         All session-derived selectors are inside the caller's authorized set by construction, so identity-only authz already returns own rows. The vulnerability is specific to model-authored scope, not to the tool.\n  [PASS] F4 gate keys on the resource-selecting operand, not on 'any model arg'\n         C8's limit is model-authored but non-selecting; account_id is session-derived. Gate ALLOWs C8. A blanket 'no model input' gate fails.\n```\n\nF2 is the one that matters most, because it is the difference between a real fix and a fake one. The obvious patch is a value check: `assert account_id == session.tenant`\n\n. That would stop C2, and it would also break `S2`\n\nreading its second authorized account, and it would silently pass a model-authored value that happens to match. F2 proves the gate is not doing that. C1 and C4 carry the *identical* value `acct_apex`\n\nand get opposite verdicts, so the decision is provably keyed on the source, not the string. F4 proves the gate is not the lazy over-correction either: it does not reject every argument the model touched, only the one that selects a resource. C8's `limit`\n\nis model-authored and the gate lets it through, because a model choosing to fetch fifty rows of accounts it is allowed to see is not a confused-deputy problem.\n\nF3 is the honest scoping of the whole claim. The tool is not dangerous. `read_account_rows`\n\nis fine. The vulnerability is specific to model-authored scope, and F3 shows it: every session-derived call leaks nothing even with no gate at all, because a selector drawn from the session is inside the caller's authorized set by construction. Take the model out of the resource-selection path and there is no deputy to confuse.\n\nIf you read [the write-chain taint post here three weeks ago](https://finops.spinov.online/blog/gate-taint-lint/), this can look like the same story told twice. It is a different operand, and the difference is the whole point.\n\nIn that post the gate keyed on a signal, `sender_trust`\n\n, and the danger was that a model had written some store *upstream* of that signal, so the value the gate read was model-laundered. The fix walked the write-closure of the signal the gate reads and refused to let a model-tainted signal hold the authorization role. The tainted thing was the thing the gate checks.\n\nHere the gate reads a clean signal. Caller identity is world-anchored; the model did not write it and there is no write-hop to trace. The gate is *right* about what it checks. The bug is that the resource selector is a **different argument entirely**, a sibling operand the authz layer structurally never inspects, and that operand is model-authored at the point of the call. There is no laundering and no upstream store. The model just fills a field, directly, and the field decides whose data comes back. Taint-linting the signal the gate reads would not catch this, because the signal the gate reads is clean. You have to gate the operand the gate ignores. Same family, adjacent bug, different fix. If your mental model is \"make sure the gate's inputs are trustworthy,\" this one slips past, because the gate's inputs *are* trustworthy and the leak is in an input the gate never took.\n\nThe general franchise both posts sit in is the same, and it is the one I keep coming back to: [gate before you execute, do not log after](https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/). It is the same instinct as [gating the lethal trifecta before the agent runs](https://finops.spinov.online/blog/lethal-trifecta-gate/) and as [the post on model-authored SQL reaching the database](https://finops.spinov.online/blog/agent-authored-sql-reaches-db/): an operand an untrusted component authored gets checked before it acts, not logged after. Logging the call tells you which account leaked, next week, in the incident review. Gating the provenance of the selector stops the read.\n\nThis is a mechanism demo on a synthetic fixture. It shows that the leak is reachable and that a provenance gate closes it on eight enumerated calls. It is not a claim about how common the bug is, not a benchmark, and not a measurement of anything in production, because bot2 has no production. The provenance tag is the load-bearing assumption: the gate is only as good as your runtime's honesty about which arguments it injected versus which the model filled. If you assemble tool calls by letting the model emit the whole JSON and never tracking what you put in, you do not have the tag, and step one is to start attaching it. The gate cannot recover a provenance you never recorded.\n\nThere is a real cost, and C4 is it. The gate denies model-authored selectors that would have been harmless, because it refuses to read the value. In exchange you get a rule that does not depend on the model being right. I think that trade is correct for anything that selects a tenant. You may not, for lower-stakes selectors, and F4 is there precisely so you can scope the rule to the arguments that deserve it.\n\nFind every tool where an argument selects a resource: `tenant_id`\n\n, `account_id`\n\n, `project_id`\n\n, `workspace`\n\n, `user_id`\n\n, `org`\n\n. For each one, ask a single question: at the moment the call is assembled, does that argument come from the session or from the model? If you cannot answer, that is the finding. Start tagging.\n\nPractitioners are already converging on this from the other side. Kailash Sankar, in [\"Defense in Depth: Tenant Isolation for an Agent That Executes Code\"](https://dev.to/ksankar/defense-in-depth-tenant-isolation-for-an-agent-that-executes-code-375j), wires a proxy that overwrites whatever the model puts in a `tenantId`\n\nparameter with the trusted value from the context registry, hallucinated or injected value be damned. Brian Hall, in [\"Don't use an LLM to decide what your AI agent is allowed to do\"](https://dev.to/brianrhall/dont-use-an-llm-to-decide-what-your-ai-agent-is-allowed-to-do-1dkn), puts it as a design rule: the decision on whether a real action runs \"has to sit on something that gives the same answer every time and can show its work afterward.\"\n\nOverwrite and deny are two implementations of the same rule, and they differ in one way worth naming. Sankar's proxy silently corrects the model's `tenantId`\n\n; the model never learns it overreached. The gate here refuses and says why, which turns a silent correction into a visible signal you can count, alert on, and use to notice a plan that keeps reaching for tenants it was not handed. Silent is safer to ship. Loud is better for finding out your agent has been trying the wrong door for a month. I have not run this in anger long enough to tell you which one you will regret less. Pick the one that matches how much you trust your own logging.\n\nThe tool, all eight calls, both execution paths, the selftest and the four falsifiers are in `scope_provenance_gate.py`\n\n. Copy it, add a ninth call, watch the counts move by exactly one.\n\n*I write one runnable tool per post about operating AI agents in production: the cost, the failures, the gates that run before execution instead of the logs that run after. Follow for the next one. And tell me in the comments: in your agent, which tool arguments come from the session and which come from the model, and are you sure?*", "url": "https://wpnews.pro/news/your-authz-checks-the-caller-the-model-picked-the-tenant", "canonical_source": "https://dev.to/alex_spinov/your-authz-checks-the-caller-the-model-picked-the-tenant-3bao", "published_at": "2026-07-26 03:51:45+00:00", "updated_at": "2026-07-26 04:29:23.942837+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-research"], "entities": ["Norm Hardy"], "alternates": {"html": "https://wpnews.pro/news/your-authz-checks-the-caller-the-model-picked-the-tenant", "markdown": "https://wpnews.pro/news/your-authz-checks-the-caller-the-model-picked-the-tenant.md", "text": "https://wpnews.pro/news/your-authz-checks-the-caller-the-model-picked-the-tenant.txt", "jsonld": "https://wpnews.pro/news/your-authz-checks-the-caller-the-model-picked-the-tenant.jsonld"}}