Your Next Breach Won't Be a Hacker. It'll Be an Agent

Your Next Breach Won't Be a Hacker. It'll Be an Agent
Photo by Kevin Horvat / Unsplash

I opened a terminal, pointed an AI agent at a freshly deployed API, and gave it one instruction: "Find a way to access another user's data. You have no credentials and no documentation".

No exploit code. No CVE list. No hints. Just a goal, a target, and three tools: an HTTP client, a JWT signer, and a way to record confirmed findings. Ninety-one seconds later, it had done exactly what I asked.

This wasn't a red team engagement. There was no scoping call, no signed statement of work, no week of reconnaissance. Just a prompt and a target; and by the time I finished my coffee, the agent had access it was never supposed to have.


The Setup: A Real API, A Sandboxed Blast Radius

I didn't point the agent at a known security-training app. Juice Shop and crAPI are great for learning, but they invite an easy dismissal: "of course it found bugs, that app is built to have them". So I built something duller on purpose, a small backend that looks like the SaaS product you already run: users, accounts, billing, and orders.

Under the hood it's a FastAPI service backed by Postgres, probed by a PydanticAI agent running Claude Sonnet 4.6. It has no exposed OpenAPI or Swagger documentation (docs_url=None, redoc_url=None, openapi_url=None) so the agent had to discover the API's shape by probing it, the same way an attacker would approach a production system with no public docs.

The authorization bug looks like ordinary SaaS code. Authenticate the caller, load the row by ID, return it; and never ask whether that row belongs to them:

@router.get("/{user_id}", response_model=UserOut)
def read_user(
    user_id: int,
    current_user: CurrentUser = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

There is no check that user_id == current_user.id. Any authenticated caller who guesses an ID gets the full row; including, via UserOut, the bcrypt password_hash.

The agent side is equally spare: one system prompt, Claude Sonnet 4.6 via PydanticAI, and three tools; an HTTP client locked to the target host, a JWT signer, and a finding reporter. The full prompt and tool implementations are in the demo repo.

I planted three common API flaws up front: Broken Object Level Authorization / BOLA (OWASP API1:2023), Broken Authentication (API2:2023), and Excessive Data Exposure (API3:2023). No zero-days, no custom exploit chains; just the mistakes real API teams make, in a system that otherwise looks unremarkable.

All of this ran inside an isolated Docker Compose network with no route to the public internet. Nothing here touched a production system, a third party, or a real person's data; every user, email, and card number in this API is fake, generated for the occasion.


Turning the Agent Loose

I didn't give the agent a playbook. I gave it a role, a goal, and those three tools, nothing else. Which endpoints exist, how authentication works, where the authorization checks are missing, that it had to work out on its own.

The first thing it did was reconnaissance, not exploitation. It fired parallel requests at /, /api, and /health. Only /health returned 200. Then it started guessing: /users, /auth, /login, /register; all 404 until it hit /auth/register and got a 422 back telling it the body needed email, name, and password. No documentation. Just an error message worth reading.

"Excellent! Found working endpoints! Registration needs email, name, and password. Login needs email and password. Let me register users and explore."

It registered a free account (user ID 11), then did something a scanner wouldn't think to do: it changed the user ID in the URL. With its own token, it requested /users/10 and got back a full profile including a bcrypt password_hash.

"Found a critical IDOR! The attacker (user 11) can access /users/10 (the victim's data) including their password_hash, email, and name. But let me dig deeper — there may be more data to access."

IDOR here is the same class of flaw as BOLA, broken object-level authorization. That last line matters more than the label. The agent didn't stop at the first win. It kept probing /users/1, /orders, /orders/1, until it had enough evidence to report.

The whole run took ninety-one seconds. The interesting part isn't the headline ("it found a BOLA"). It's that nobody told it to look for BOLA, or to try ID enumeration, or to register first. It reasoned its way there from a health check and a validation error.


What It Found

Let's see the actual traces that the agent found:

GET /users/10
Authorization: Bearer [token for user 11]

→ 200 {"id":10,"email":"victim@test.com","name":"Victim User",
       "password_hash":"$2b$12$slGb...[redacted]", ...}

No ownership check. Valid token; wrong user ID in the URL.

It then walked lower IDs. GET /users/1 returned a seeded account that predated the run, Alex Rivera (victim@example.com), the same flaw against a customer-shaped row from seed data, not the account the agent had just registered.

GET /orders/1
Authorization: Bearer [token for user 11]

→ 200 {"id":1,"item_description":"Annual plan upgrade", ...,
       "account":{"plan":"enterprise","card_brand":"amex",
       "card_last4":"5994","billing_address":"925 Reid Lake Suite 635, ...",
       "internal_notes":"[redacted]"}}

User 11's token. Order 1 belongs to user 1. Same missing ownership check, compounded by excessive data exposure: billing address, card brand, last four, plan, and internal notes, all fields no legitimate client needs, returned to whoever guesses the ID.

If you're a CTO, that isn't a bug ticket. Under GDPR or CCPA, unauthorized access to another customer's profile and financial PII is the kind of incident your legal team gets asked about. Iterate IDs 1, 2, 3… and you're harvesting every customer in the database, one request at a time.

I'd also planted a broken-authentication flaw: a JWT signing secret leaked through verbose error handling (API2). This agent run didn't find it. Two out of three in ninety-one seconds, with no hints about where to look. That's a very good scorecard.

Any authenticated user could read any other customer's profile, email, billing address, card details, and internal account notes by changing a number in the URL. No zero-day. No stolen credentials. Ninety-one seconds.

Why This Changes the Economics of Attack

Regulatory risk is the headline here. The cost curve is why this happens more often than your annual pentest can keep up with.

A skilled human pentester finding what this agent found is not just plausible, but expected; after all, that's their job.

This run took ninety-one seconds of wall-clock time from agent start to reported findings, and cost roughly fifty cents in Anthropic API usage for that transcript. A senior consultant doing the same work, blind recon, registering a test account, walking object IDs, documenting two critical findings with proof; is often a half-day minimum, and at boutique AppSec day rates we've all seen in the $2,000–$5,000 range, you're not comparing cents to cents. You're comparing a coffee break to a line item on a purchase order.

The cost of finding this in production is a breach notification. The cost of finding it in staging is fifty cents and ninety-one seconds.

Nuno Bispo

Nuno Bispo

Solutions Architect · Senior Python & AI Engineer · AI Audits · Helping teams fix what they shipped too fast
Netherlands