Scanners catch misconfigurations. They miss the authorization flaws that cause real breaches. Here is an agent that does not.
Highlights
- Broken object level authorization tops the OWASP API Security Top 10 and remains close to invisible to conventional scanners, because detecting it requires knowing which user should own which record.
- Akamai's 2026 API Security Impact Survey found 87 percent of organizations hit by an API security incident, at an average cost near 700,000 dollars per incident.
- An agent works differently from a scanner because it reads your OpenAPI specification, infers ownership relationships, and then designs cross tenant probes that no signature database contains.
- Safety comes first in the build, with an allowlist of targets, blocked destructive methods, rate limiting, and a hard refusal to run outside authorized scope.
- Every finding carries replayable evidence, since an authorization report without the exact request and response pair is an opinion rather than a defect.
- Findings export as SARIF, which drops them straight into GitHub code scanning and turns your test run into a pull request gate.
- Confirmed findings become a permanent regression corpus, so the same flaw can never quietly return in a later release.
The 2026 edition of Akamai's API Security Impact Survey, fourth in the series, reported that 87 percent of organizations suffered an API security incident, at an average cost close to 700,000 dollars. The gap that produces those numbers is specific and measurable: the top risk on the OWASP API Security Top 10 is broken object level authorization, and it is nearly invisible to the automated scanners most teams rely on. Salt Security's research adds the detail that explains why, reporting that the overwhelming majority of API attacks arrive through authenticated sessions. The attacker is not bypassing your login. They are logging in legitimately and then reading somebody else's records.
A scanner cannot catch that, because there is no malformed payload and no error signature to match. A request for order 1043 looks exactly like a request for order 1044. Deciding whether the second one is a vulnerability requires knowing that the caller owns the first and not the second, which is business context rather than pattern matching. That is precisely the gap an AI agent for API security testing fills, and this tutorial builds one in Python from an OpenAPI specification to a SARIF report your pipeline can gate on.
What an AI agent for API security testing is
An AI agent for API security testing is a language model with HTTP tools that reads an API specification, reasons about which endpoints expose object ownership or privileged functions, generates and executes targeted probes against an authorized environment, and reports each confirmed weakness with the exact request and response pair that proves it.
The distinction from a scanner is where the test cases come from. A scanner carries a fixed library of checks and applies them to whatever it finds. An agent derives its checks from your specification, which is why it can reason that GET /accounts/{accountId}/statements implies an ownership boundary worth testing while GET /health does not.
Rules of engagement, before any code
This is a tool that sends adversarial traffic at a running system, so scope discipline is part of the build rather than a disclaimer at the end.
Test only systems you own or have written authorization to test, since unauthorized testing is unlawful in most jurisdictions regardless of intent. Point the agent at staging or a dedicated test environment rather than production, because authorization probes create records, trigger alerts, and consume quota. Use purpose made test accounts with seeded data, never real customer identities. Confirm findings to the minimum degree that proves the flaw, which means reading one unauthorized record rather than enumerating the table. Coordinate with whoever watches your alerts, because an effective run looks exactly like an attack in your logs, and that is the point.
Encode those rules in the tool layer rather than in a prompt, because a prompt is advice and code is enforcement.
Architecture
Five stages, each feeding the next.
The loader parses the OpenAPI specification into a normalized endpoint inventory with paths, methods, parameters, and declared authentication. The planner asks the model which endpoints carry object ownership or privileged function semantics, and what a violation would look like for each. The executor runs probes through a guarded HTTP client that enforces scope, method, and rate limits. The verifier replays anything that looks like a finding using a second identity to eliminate false positives. The reporter deduplicates, scores, and emits SARIF plus a human readable summary.
Step 1: Turn the specification into an inventory
Everything starts with the spec, because it encodes the ownership relationships the agent needs to reason about.
from dataclasses import dataclass, field
import yaml
@dataclass
class Endpoint:
path: str
method: str
operation_id: str
path_params: list = field(default_factory=list)
query_params: list = field(default_factory=list)
requires_auth: bool = False
summary: str = ""
def load_spec(path: str) -> list[Endpoint]:
"""Normalize an OpenAPI 3.x document into a flat endpoint inventory."""
spec = yaml.safe_load(open(path))
global_security = bool(spec.get("security"))
endpoints = []
for route, operations in spec.get("paths", {}).items():
shared = operations.get("parameters", [])
for method, op in operations.items():
if method not in {"get", "post", "put", "patch", "delete"}:
continue
params = shared + op.get("parameters", [])
endpoints.append(Endpoint(
path=route,
method=method.upper(),
operation_id=op.get("operationId", f"{method}_{route}"),
path_params=[p["name"] for p in params if p.get("in") == "path"],
query_params=[p["name"] for p in params if p.get("in") == "query"],
requires_auth=bool(op.get("security", global_security)),
summary=op.get("summary", ""),
))
return endpointsThe path_params list is the important output. An endpoint with a path parameter that names an object, such as {accountId} or {invoiceId}, is a candidate for the authorization tests that follow. An endpoint with no object reference usually is not.
Step 2: Build the guarded HTTP client
This is the safety boundary. The agent never gets a raw HTTP library.
import time
import httpx
from urllib.parse import urlparse
class ScopeViolation(Exception):
"""Raised when the agent tries to act outside authorized scope."""
class GuardedClient:
BLOCKED_METHODS = {"DELETE"} # destructive by default
MIN_INTERVAL = 0.2 # 5 requests per second ceiling
def __init__(self, allowed_hosts: set[str], identities: dict[str, dict]):
self.allowed_hosts = allowed_hosts
self.identities = identities # {"tenant_a": {...headers}, ...}
self._last_call = 0.0
self._log: list[dict] = []
def request(self, method: str, url: str, identity: str,
json_body: dict | None = None) -> dict:
host = urlparse(url).netloc
if host not in self.allowed_hosts:
raise ScopeViolation(f"{host} is not in the authorized scope")
if method.upper() in self.BLOCKED_METHODS:
raise ScopeViolation(f"{method} is blocked for this run")
if identity not in self.identities:
raise ScopeViolation(f"unknown test identity {identity}")
wait = self.MIN_INTERVAL - (time.monotonic() - self._last_call)
if wait > 0:
time.sleep(wait)
response = httpx.request(
method, url, headers=self.identities[identity],
json=json_body, timeout=15.0, follow_redirects=False,
)
self._last_call = time.monotonic()
record = {
"method": method, "url": url, "identity": identity,
"status": response.status_code,
"body": response.text[:2000], # truncated evidence
"elapsed_ms": int(response.elapsed.total_seconds() * 1000),
}
self._log.append(record) # every call is evidence
return recordFour properties make this safe enough to run in a shared environment. The host allowlist means a hallucinated URL raises an exception rather than reaching a third party. Destructive methods are blocked by default and enabled only deliberately. Rate limiting keeps a testing run from becoming a denial of service. Every request and response is logged, which serves both as evidence for findings and as an audit trail for whoever asks what the tool did.
Step 3: Teach it the test scanners cannot run
Here is the core of the tutorial. Broken object level authorization is detected by comparing what two identities can see, and that comparison is mechanical once you have the right two requests.
@dataclass
class Finding:
rule_id: str
severity: str
endpoint: str
title: str
evidence: dict
remediation: str
def test_bola(client: GuardedClient, base_url: str, endpoint: Endpoint,
owned_ids: dict[str, str]) -> Finding | None:
"""
Ownership probe. Identity A holds a resource. Identity B asks for it.
A correct API answers 403 or 404. A 200 with matching content is BOLA.
"""
if not endpoint.path_params or endpoint.method != "GET":
return None
param = endpoint.path_params[0]
victim_id = owned_ids.get(param) # object owned by tenant_a
if not victim_id:
return None
url = base_url + endpoint.path.replace("{" + param + "}", victim_id)
baseline = client.request("GET", url, identity="tenant_a")
if baseline["status"] != 200:
return None # seed data wrong, skip quietly
probe = client.request("GET", url, identity="tenant_b")
if probe["status"] != 200:
return None # correctly denied
if probe["body"][:200] != baseline["body"][:200]:
return None # different content, not a leak
return Finding(
rule_id="API1-BOLA",
severity="high",
endpoint=f"{endpoint.method} {endpoint.path}",
title="Cross tenant object access through path parameter",
evidence={"authorized": baseline, "unauthorized": probe},
remediation=(
"Check object ownership server side on every request that accepts "
"an object identifier. Do not rely on identifier unpredictability."
),
)Read what that function proves. It establishes that tenant A legitimately sees a record, then shows tenant B receiving byte identical content for the same object. That is not a heuristic, it is a demonstration, and it is why the finding ships with both halves of the comparison attached.
The same shape covers broken function level authorization. Swap the second identity for a standard user and target an administrative operation, and a 200 response where you expected a 403 is your finding.
Want a cluster and API to break safely?
Security testing is a skill you build by attacking things you are allowed to attack. The DevSecOps learning path on KodeKloud covers pipeline security and testing practice, the Python courses build the scripting foundation this agent depends on, and the KodeKloud playgrounds give you disposable environments where you can host a deliberately vulnerable API and run your agent against it without touching anything that matters.
Step 4: Let the model plan, keep the tools deterministic
The model's job is judgment about which endpoints matter and what a violation looks like. The execution stays in code you wrote.
import json
PLANNER_PROMPT = """You are an API security analyst. Given this endpoint
inventory, identify endpoints that carry authorization risk.
For each, return an object with:
operation_id, risk ("BOLA"|"BFLA"|"BOPLA"|"none"),
reason (one sentence), ownership_param (path param naming an object, or null)
Prioritize endpoints whose path parameters reference user owned objects and
endpoints whose summary implies administrative or privileged behavior.
Return only a JSON array.
Inventory:
{inventory}"""
def plan_tests(endpoints: list[Endpoint], llm) -> list[dict]:
inventory = [
{"operation_id": e.operation_id, "method": e.method, "path": e.path,
"path_params": e.path_params, "summary": e.summary}
for e in endpoints
]
raw = llm(PLANNER_PROMPT.format(inventory=json.dumps(inventory, indent=2)))
text = raw.strip()
if text.startswith("`"): # strip a markdown fence if present
text = text.strip("`").removeprefix("json").strip()
plan = json.loads(text)
return [item for item in plan if item.get("risk") != "none"]Splitting responsibilities this way matters more than it might look. The model never emits a URL that gets called blindly, never chooses an HTTP method, and never decides whether something is a finding. It reads names and summaries and says where to look, which is the part it is genuinely good at, while every consequential action runs through the guarded client and every verdict comes from a deterministic comparison.
Step 5: Report evidence, not opinions
A finding without a reproduction is a ticket that gets closed as "cannot reproduce." Emit SARIF so findings land in GitHub code scanning alongside your other results.
def to_sarif(findings: list[Finding]) -> dict:
return {
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {"driver": {
"name": "api-security-agent",
"informationUri": "https://example.internal/api-security-agent",
"rules": [{
"id": f.rule_id,
"shortDescription": {"text": f.title},
"help": {"text": f.remediation},
} for f in {f.rule_id: f for f in findings}.values()],
}},
"results": [{
"ruleId": f.rule_id,
"level": {"high": "error", "medium": "warning"}.get(f.severity, "note"),
"message": {"text": (
f"{f.title} on {f.endpoint}. "
f"Authorized request returned {f.evidence['authorized']['status']}, "
f"unauthorized identity returned {f.evidence['unauthorized']['status']} "
f"with matching content."
)},
"locations": [{"physicalLocation": {
"artifactLocation": {"uri": "openapi.yaml"},
}}],
} for f in findings],
}],
}Wire it into the pipeline so results gate the merge rather than filling a report nobody opens.
name: API security agent
on:
pull_request:
paths: ["openapi.yaml", "src/api/**"]
jobs:
agent-scan:
runs-on: ubuntu-latest
permissions:
security-events: write # required to upload SARIF
contents: read
steps:
- uses: actions/checkout@v4
- name: Run the agent against staging
env:
TARGET_HOST: staging-api.internal
TENANT_A_TOKEN: ${{ secrets.TENANT_A_TOKEN }}
TENANT_B_TOKEN: ${{ secrets.TENANT_B_TOKEN }}
run: python -m agent.run --spec openapi.yaml --out results.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarifStep 6: Prove the agent actually works
Every security tool claims coverage. Measure yours before you trust it, using an API with known planted flaws.
Deliberately vulnerable applications exist for exactly this purpose, and OWASP maintains several, including crAPI and VAmPI, both of which ship documented authorization flaws. Run your agent against one and count what it catches. A detection rate below your expectation usually means the planner is missing endpoints rather than that the probe logic is wrong, which points you at prompt and inventory improvements rather than a rewrite.
Then build the piece that pays off over time. Every confirmed finding becomes a permanent regression test.
# corpus/regressions.yaml grows one entry per confirmed finding
# - rule_id: API1-BOLA
# endpoint: "GET /accounts/{accountId}/statements"
# probe_identity: tenant_b
# expect_status: [403, 404]
def run_regressions(client: GuardedClient, base_url: str, corpus: list[dict]):
"""Fast deterministic replay. No model calls, runs on every commit."""
failures = []
for case in corpus:
url = base_url + case["endpoint"].split(" ", 1)[1].format(**case["params"])
result = client.request("GET", url, identity=case["probe_identity"])
if result["status"] not in case["expect_status"]:
failures.append({"case": case, "got": result["status"]})
return failuresThat corpus is the quiet win of the whole design. Agent runs are exploratory, cost tokens, and take minutes. Regression runs are deterministic, free, and take seconds, so they belong on every commit while the full agent run belongs on a schedule or on specification changes.
What the agent covers, and what it does not
| OWASP API risk | Agent testable | How |
|---|---|---|
| API1 Broken object level authorization | Strong fit | Two identity ownership probe with content comparison. |
| API2 Broken authentication | Good fit | Missing, expired, malformed, and swapped tokens against protected routes. |
| API3 Broken object property level authorization | Good fit | Compare response fields against the schema and flag unexpected sensitive properties. |
| API4 Unrestricted resource consumption | Partial | Pagination and page size probes, though genuine load testing needs a different tool. |
| API5 Broken function level authorization | Strong fit | Standard identity against administrative operations. |
| API6 Unrestricted access to sensitive business flows | Weak fit | Requires business meaning the specification does not encode. |
| API7 Server side request forgery | Partial | Probe URL accepting parameters with benign internal targets only. |
| API8 Security misconfiguration | Better elsewhere | Conventional scanners and configuration review do this well already. |
| API9 Improper inventory management | Good fit | Compare the specification against observed routes to find undocumented endpoints. |
| API10 Unsafe consumption of third party APIs | Weak fit | Needs visibility into upstream integrations rather than your own surface. |
Be honest about the fit column when you present results. An agent that claims full coverage of the Top 10 loses credibility with the first security engineer who reads the output carefully.
| Approach | Strength | Blind spot |
|---|---|---|
| Your agent | Authorization logic derived from your specification. | Needs a good spec and seeded test identities. |
| OWASP ZAP | Broad, free, mature injection and misconfiguration coverage in CI. | Cannot infer ownership relationships. |
| Schemathesis | Property based testing that finds specification and contract violations. | Not an authorization tester. |
| Burp Suite | Unmatched for manual exploration and complex chained flows. | Human driven, so it does not run on every pull request. |
| Commercial API security platforms | Managed rule sets, traffic discovery, and support. | Cost, and rules that do not know your domain. |
Where to start
- Confirm you have written authorization to test the target, and pick a staging environment rather than production.
- Export or generate the OpenAPI specification for one service, since inventory quality caps everything downstream.
- Seed two test tenants with realistic data and record which object identifiers each one legitimately owns.
- Build the guarded client first, with the host allowlist and rate limit, and verify a scope violation raises before you add any model calls.
- Implement the ownership probe alone, run it against a deliberately vulnerable API, and confirm it catches a known planted flaw.
- Add the planner once probes work, because a smart planner in front of broken probes produces confident nonsense.
- Emit SARIF, upload it in CI, and convert every confirmed finding into a regression case the same day.
Conclusion
The reason authorization flaws dominate API breach reports is not that they are hard to understand. They are hard to test at scale, because catching one means knowing which user should own which record, and that knowledge lives in your domain model rather than in any scanner's rule database. Reading the specification, inferring those relationships, and designing the probe is exactly the work a language model does well, while the parts that must never be improvised, scope enforcement, request execution, and the verdict itself, stay in deterministic code.
Build the guarded client this week and write one ownership probe against a staging service you own. If it comes back clean, you have a regression test worth keeping. If it does not, you have found the class of flaw that costs organizations an average near 700,000 dollars per incident, and you found it before somebody else did.
Ready to Build the Skills Behind This?
An agent is only as good as the engineer who scoped it. The DevSecOps learning path on KodeKloud covers security testing inside real pipelines, the Python courses build the scripting depth this tutorial assumes, and the KodeKloud playgrounds give you disposable environments where you can stand up a vulnerable API and attack it safely. Pick one and start today.
FAQs
Q1: What do I need to know before building an API security testing agent?
You need solid Python and a working understanding of HTTP and authentication rather than any machine learning background. Specifically, comfort with typed Python and an HTTP client library covers the implementation, familiarity with OpenAPI structure lets you build the inventory correctly, and understanding of token based authentication schemes such as OAuth 2.0 and JWT is necessary because the agent operates entirely through authenticated sessions. On the security side, read the OWASP API Security Top 10 before you write a probe, since knowing what broken object level authorization actually means is what separates a useful tool from a request generator. Basic CI knowledge helps for the pipeline integration, and SARIF is worth thirty minutes of reading because it is what makes findings appear natively in code scanning. If you want structured practice, the Python courses on KodeKloud build the scripting foundation and the DevSecOps learning path covers where security testing fits in a delivery pipeline.
Q2: Is it legal and safe to run this against my company's APIs?
It is legal when you have authorization and unlawful when you do not, so get written approval before the first run, even for systems your own team operates. Practically, that means a documented scope covering which hosts and environments are in bounds, a named approver, and a defined time window, which is exactly what a professional penetration testing engagement produces. Run against staging rather than production, because authorization probes create records, consume rate limit budget, and generate alerts that pull people away from real work. Tell your security operations team before you start, since a working agent looks identical to an attacker in the logs, and an undisclosed test that triggers an incident response burns goodwill you will need later. Use dedicated test accounts with seeded data rather than real customer identities, and confirm each flaw to the minimum degree that proves it exists rather than enumerating everything reachable.
Q3: How is this different from running OWASP ZAP or a commercial scanner?
The difference is where the test cases come from and therefore what class of flaw gets caught. Scanners carry a library of checks for known vulnerability patterns, which makes them excellent at injection, misconfiguration, missing headers, and exposed debug endpoints, and they are fast, deterministic, and cheap to run on every build. What they cannot do is infer that a request for another tenant's invoice should fail, because that requires knowing your ownership model rather than matching a signature, which is why broken object level authorization tops the OWASP list while remaining largely invisible to conventional tooling. An agent derives its probes from your specification and confirms them by comparing two identities, so it covers the authorization gap specifically. These are complements rather than competitors, and the sensible production setup runs a scanner on every pull request for broad coverage and the agent on specification changes or a nightly schedule for authorization depth.
Q4: How do I stop the agent from producing false positives?
Design the verdict logic so the model never issues one. In the pattern shown here the model plans which endpoints to examine and why, while the decision about whether something is a finding comes from a deterministic comparison: an authorized request returned 200, an unauthorized identity returned 200, and the response bodies match. Three additional habits keep the report clean. Establish a baseline first, so an endpoint that fails for both identities is skipped rather than reported. Compare content rather than status codes alone, because some APIs return 200 with an empty result set for unauthorized access, which is correct behavior. Replay every candidate finding before reporting it, since a transient failure or a rate limit response can otherwise look like a leak. Teams that let the model decide severity end up with confident, wrong reports that destroy trust in the tool after the first review meeting.
Q5: What does this cost to run, and how do I keep it affordable?
Costs stay modest because the expensive component runs rarely. The model is called for planning, roughly once per specification, and optionally for triage of ambiguous results, while the probes themselves are plain HTTP requests that cost nothing. For a service with a hundred endpoints, planning is a single call with a few thousand tokens of inventory, which lands in cents rather than dollars. The controls that matter are structural: cache the plan and regenerate it only when the specification changes, run the deterministic regression corpus on every commit while reserving full agent runs for scheduled jobs, cap probes per endpoint so a large inventory cannot produce a runaway run, and use a smaller model for planning if your specification is well named. Teams that skip these controls usually do so by regenerating the plan on every pull request, which is pure waste when the specification has not changed.
Q6: Can the agent test GraphQL or gRPC APIs, or only REST?
The architecture transfers but the loader and probe layers need work for each protocol. For GraphQL, introspection replaces the OpenAPI document as the inventory source and the ownership probe operates on node identifiers inside queries rather than path parameters, which actually makes authorization testing more interesting because a single endpoint exposes many object types and nested resolvers can leak data the top level query does not. For gRPC, the protobuf definitions provide the inventory and the guarded client wraps a gRPC channel rather than an HTTP client, with the same two identity comparison applied to unary calls. The concepts that carry across are the ones worth internalizing: derive tests from a machine readable contract, enforce scope in code, prove findings by comparison rather than assertion, and keep confirmed findings as regressions. Start with REST because the tooling is simplest, then port the pattern once the probe logic is proven.
Discussion