August 4, 2026
Your AI Agent Has an IDOR Problem
Everyone’s hardening LLM agents against prompt injection. The bug that actually leaks your data is broken object-level authorization…

By Serhat ÇİÇEK
6 min read
- 1 Everyone's hardening LLM agents against prompt injection. The bug that actually leaks your data is broken object-level authorization (BOLA/IDOR) in a tool call — a missing if.
- 2 How AI Agents Call Tools
- 3 Why Tool Calling Skips Authorization
- 4 Setting Up the Vulnerable AI Agent
- 5 Exploiting the IDOR: Cross-Tenant Data Exposure
Everyone's hardening LLM agents against prompt injection. The bug that actually leaks your data is broken object-level authorization (BOLA/IDOR) in a tool call — a missing if.
Most teams building AI agents obsess over prompt injection and miss a classic web-security bug hiding in the tool-calling layer: IDOR, insecure direct object reference. Say "AI security" and people picture jailbreaks, or a model coughing up its system prompt. But LLM agents are now so wired into tools that an old-school broken-access-control flaw can resurface straight through the AI.
How AI Agents Call Tools
A tool is just a function you expose to the model — an API lookup, a SQL query, a send_email. On its own a language model can only emit text; tool calling (a.k.a. function calling) is the protocol that lets it act. The flow is a round trip, and it's nearly identical across providers.
1 — You declare the tools. Each is a name, a description, and a JSON-Schema for its arguments. In OpenAI's function calling (Responses API), a definition is flat:
{
"type": "function",
"name": "query_api_record",
"description": "Look up the payment API log for an order",
"parameters": {
"type": "object",
"properties": { "orderId": { "type": "string" } },
"required": ["orderId"]
}
}{
"type": "function",
"name": "query_api_record",
"description": "Look up the payment API log for an order",
"parameters": {
"type": "object",
"properties": { "orderId": { "type": "string" } },
"required": ["orderId"]
}
}2 — The model decides to call one. Instead of prose, the API returns a function call: the tool name plus the arguments the model generated, as a JSON string (not an object):
{
"type": "function_call",
"call_id": "call_abc123",
"name": "query_api_record",
"arguments": "{\"orderId\":\"1042\"}"
}{
"type": "function_call",
"call_id": "call_abc123",
"name": "query_api_record",
"arguments": "{\"orderId\":\"1042\"}"
}3 — Your backend runs it and returns the result. The model never touches your database. Your code executes query_api_record("1042") and hands the output back (as a function_call_output keyed by call_id), which the model turns into a natural-language answer.
The shape differs slightly by vendor, but the contract is the same. Anthropic's tool use declares tools with an input_schema, returns a tool_use block (id, name, input), and you reply with a tool_result referencing that tool_use_id.
That third step is the one that matters. It runs on your infrastructure, under whatever privileges you handed the tool. The model only picked the function and filled in an argument. So the interesting security question isn't "what did the model say?" It's what the tool does with a model-supplied argument, and whose identity it runs under.
Why Tool Calling Skips Authorization
I first hit this on a real bug bounty program — an AI copilot whose tool took an id straight from the model and ran an unscoped lookup against a shared, multi-tenant datastore. I reported the IDOR through responsible disclosure, and it's since been fixed. The lab further down is an anonymized reproduction of that same class of vulnerability."o
There are two authorization questions in any agent, and teams routinely nail the first while forgetting the second:
Layer: Authentication / session The question it answers: "Are you a logged-in user?" Usually…: ✅ enforced
Layer: Object-level authorization The question it answers: "Does this specific record belong to you?" Usually…: ❌ often missing on the tool
The chat endpoint almost always checks the first. You can't talk to the assistant without a valid session. But the tool frequently skips the second: it takes the orderId the model produced, runs the lookup, and returns whatever it finds — for any tenant, any user, any object.
And that argument is untrusted input. It doesn't matter that a language model generated it; the model is just a very persuasive proxy for the user, who typed "look up order 1042." Trusting a model-supplied id is the same mistake as trusting a raw ?id= query parameter — the exact pattern behind IDOR / BOLA (Broken Object-Level Authorization), which sits at the top of the OWASP API Security Top 10 as API1:2023.
The AI version is worse because of the privilege gap. To be useful, the tool is usually wired to a broad, operator-grade backend that can read across tenants. So the agent becomes a confused deputy. It holds more privilege than the person driving it, and it'll spend that privilege on whatever they ask. The user can see their own orders. The agent can see everyone's, and it cheerfully connects the two.
This is why "AI guardrails" don't save you. A system-prompt rule like "only return the user's own data" is a suggestion, sitting in the exact layer an attacker manipulates and the model can simply get wrong. Authorization can't live anywhere the model — or the user's words — can move it. It belongs below the model, in the tool, checked against the session.
Setting Up the Vulnerable AI Agent
The lab is open source and runs in about two minutes. No API key, no cloud; it ships with a deterministic mock model, so everything works offline.
Repo: github.com/Serhatcck/idor-in-ai-tools
Requirements: Node.js 20.6+ (built with v24).
git clone https://github.com/Serhatcck/idor-in-ai-tools
cd idor-in-ai-tools
npm install
npm startgit clone https://github.com/Serhatcck/idor-in-ai-tools
cd idor-in-ai-tools
npm install
npm startYou'll see:
PayPortal vulnerable lab -> http://localhost:8989PayPortal vulnerable lab -> http://localhost:8989Open http://localhost:8989 and you're auto-logged-in as a low-privilege sandbox merchant. You get a realistic multi-tenant payment dashboard — KPIs, a list of your orders, and an AI copilot in the bottom-right corner.
It also ships with a built-in security inspector (on by default) that annotates every copilot tool call. We'll use it in the next section to watch the bug fire. To toggle it, press i or add ?inspect=0 to the URL.
Want to prove it's not a quirk of one model? Copy .env.example to .env and point it at any OpenAI-compatible endpoint — OpenAI, OpenRouter, Groq, a local Ollama, or an MCP-style tool server:
cp .env.example .env
# set LLM_BASE_URL / LLM_API_KEY / LLM_MODEL, then:
npm startcp .env.example .env
# set LLM_BASE_URL / LLM_API_KEY / LLM_MODEL, then:
npm startA "🤖 live LLM" toggle appears in the copilot. The exploit plays out identically. Same missing check, real model.
Exploiting the IDOR: Cross-Tenant Data Exposure
With the app running and the inspector on, open the copilot and ask about an order that isn't yours:
"Look up the API logs for order 1004."
You get a friendly answer with the order details. But now the inspector attaches a tool-call receipt under the reply, and this is the whole vulnerability on one screen:
TOOL CALL query_api_record(orderId: "1004")
──────────────────────────────────────────────
current user you · tenant 8811…0001
record owner tenant 8811…0005 ← belongs to someone else
ownership check ✗ NOT PERFORMED
──────────────────────────────────────────────
VERDICT ⚠ Cross-tenant readTOOL CALL query_api_record(orderId: "1004")
──────────────────────────────────────────────
current user you · tenant 8811…0001
record owner tenant 8811…0005 ← belongs to someone else
ownership check ✗ NOT PERFORMED
──────────────────────────────────────────────
VERDICT ⚠ Cross-tenant readThree rows, and that's the whole bug. Your tenant. The record's actual owner. And a line saying nobody checked whether those two match. The status bar flips to ✓ Cross-tenant data exposed. Ask about one of your own orders and the same receipt turns green — the boundary holds for your ids and quietly evaporates for anyone else's.
That's one record. To see how bad it gets, the inspector has a one-click enumeration button (or run the included script) that sweeps orders 1000–1059 through the copilot:
npm run exploit
=== Copilot mode: VULNERABLE ===
CROSS-TENANT (leaked): 57
Distinct victim tenants: 7
>>> a sandbox account read 7 other tenants' datanpm run exploit
=== Copilot mode: VULNERABLE ===
CROSS-TENANT (leaked): 57
Distinct victim tenants: 7
>>> a sandbox account read 7 other tenants' dataSo: a sandbox account, a guessable range of ids, and you've pulled dozens of other tenants' customer records — names, emails, phone numbers — out through a chat box. The first time I watched this against my own toy app, I assumed I'd mis-scoped the test data. I hadn't. The tool really was handing back every tenant it was asked for. No prompt injection anywhere; the AI just did what it was told, and the tool never asked whether it should.
The Fix: Object-Level Authorization Inside the Tool
You don't fix this with a smarter model or a cleverer prompt. You fix it with an ownership check inside the tool, keyed to the session identity — not to anything the model produced or the user typed.
The caller's tenant comes from the authenticated session (a cookie or JWT the user can't forge), and every lookup is scoped to it:
return (orderId) => {
// caller.merchantId comes from the SESSION, never from the model/user
const rec = queryScoped(orderId, caller.merchantId);
if (!rec) return { ok: false, json: '{ "error": "forbidden" }' };
return { ok: true, json: JSON.stringify(rec) };
};
function queryScoped(orderId, callerMerchantId) {
const rec = db.find(orderId);
if (!rec) return null;
if (rec.merchantId !== callerMerchantId) return null; // ← the entire fix
return rec;
}return (orderId) => {
// caller.merchantId comes from the SESSION, never from the model/user
const rec = queryScoped(orderId, caller.merchantId);
if (!rec) return { ok: false, json: '{ "error": "forbidden" }' };
return { ok: true, json: JSON.stringify(rec) };
};
function queryScoped(orderId, callerMerchantId) {
const rec = db.find(orderId);
if (!rec) return null;
if (rec.merchantId !== callerMerchantId) return null; // ← the entire fix
return rec;
}One comparison, and that's the entire patch. In the lab you can flip the resolver to Fixed — the code slide-over has a toggle that swaps it live — and re-run the exact same attack:
=== Copilot mode: FIXED ===
CROSS-TENANT (leaked): 0
>>> ownership check blocked every foreign order id=== Copilot mode: FIXED ===
CROSS-TENANT (leaked): 0
>>> ownership check blocked every foreign order idSame UI, same model, same enumeration, and now the copilot returns only your own orders while every foreign id comes back forbidden. That one line moved the authorization decision out of "wherever the model felt like it" and into the session that actually owns the request. The rule I'd give anyone building agents:
An LLM may decide** which tool to call. It must never be what authorizes **the call.
Key Takeaways: Securing AI Agent Tool Calls
Prompt injection gets the headlines. Meanwhile the thing most likely to actually leak your customers' data is dumber than that — a tool trusts an id it was handed and never checks who owns it. It's IDOR / BOLA, and giving the model tools just handed attackers a friendlier way to reach it. The same hole shows up anywhere an agent tool takes an object id: MCP tool servers, RAG retrievers, invoice and ticket lookups.
If you build agentic AI, audit your tools this week. For each one, find the line where a model-supplied argument becomes a database read, and ask whose identity you checked. If the answer is "the login," you have an object-level authorization gap — and it's usually a one-line fix.
If you take one thing from this: logging a user in tells you nothing about whether a given record is theirs. That check has to live in the tool, against the session — not in a prompt, a guardrail, or a bigger model.
Clone the lab, break it, fix it: github.com/Serhatcck/idor-in-ai-tools