TL;DR: Your API’s input is an attack surface, so test it like one. Write negative cases that send oversized fields, wrong types, malformed bodies, and injection strings, then assert the endpoint answers a 4xx and never a 5xx. Turn schema validation into a security control with additionalProperties: false, enums, and length limits. Run the whole suite in CI on every change. AI agents make this urgent: they generate and forward payloads at machine speed, so “load this data” quietly becoming “run this code” now scales.Most test suites prove your API works when the caller is polite. You send a valid body, you get a 200, the assertion passes. That result tells you almost nothing about what happens when the body is hostile. Untrusted input is any data your endpoint did not generate itself: request bodies, query strings, headers, file uploads, webhook payloads, and the JSON an AI agent assembles on the fly. All of it deserves the same assumption, which is that someone will eventually send the worst possible version of it.
In July 2026, Hugging Face described a security incident whose entry vector was data, not a stolen password. We covered the lessons from that breach separately; this guide is the hands-on half. You will build tests that send the kind of input an attacker sends, then run them automatically on every change. The categories line up with the OWASP API Security Top 10, which is worth keeping open in a tab. Apidog is one way to design the contract and drive these tests, but the ideas hold in any framework you already use.
Input is an attack surface, not a form field
Validation is often treated as user-experience politeness: catch the empty email, show a red border, move on. That framing is the problem. Every field your API accepts is a promise the caller can break, and each broken promise is a path into your logic. A limit parameter you expected to be a small integer becomes 999999999. A filename you expected to be a single word becomes ../../etc/passwd. A config object you expected to hold settings becomes a set of instructions.
Security testing is not a separate discipline bolted on at the end. It is the same negative testing you already know, aimed at the fields most likely to hurt you. If you build the habit of asking “what is the worst thing that fits in this field,” you are most of the way toward the practices in our API security best practices guide. The rest of this article turns that one question into concrete tests you can run.
How “load this data” became “run this code”
The Hugging Face incident is a clean example of why input deserves this attention. Hugging Face said the entry vector was malicious datasets: a crafted dataset triggered a remote-code dataset loader, and a template injection lived inside a dataset configuration. You can read the company’s own account in its security incident report.
Sit with the shape of that failure. An endpoint accepted something described as data. Loading that data ran a code path that could execute attacker-controlled instructions. “Load this data” became “run this code.” The template injection is the same story at a smaller scale: a configuration value that was supposed to be inert text got evaluated, so text became execution.
The takeaway is not “Hugging Face made a rare mistake.” It is that any endpoint accepting a loader name, a format, a template, a serialized object, or a config blob is accepting instructions, whether or not you meant it to. If you never wrote a test that sends a hostile config to that endpoint, you never actually checked the assumption that it stays inert. That untested assumption is the whole vulnerability.
Schema validation as a security control
The cheapest control you can add is a strict schema at the edge. A schema is not only documentation. When you reject anything that does not match, the schema becomes a filter that runs before your business logic ever sees the request. JSON Schema gives you the primitives to make that filter tight.
Here is a schema for the dataset config from the story, written so that most hostile input never reaches application code:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["loader", "name"],
"properties": {
"loader": { "enum": ["csv", "json", "parquet"] },
"name": { "type": "string", "maxLength": 128, "pattern": "^[\\w .-]+$" },
"rows": { "type": "integer", "minimum": 0, "maximum": 1000000 }
}
}
Read it as four separate defenses. additionalProperties: false rejects a smuggled template field outright, so an attacker cannot add one. The loader enum means pickle:// or any remote-code loader is simply not a valid value. maxLength kills the multi-megabyte string aimed at exhausting memory. The pattern on name refuses the {{ and '; DROP TABLE characters before they travel any further. None of these lines know about attackers. They only accept the narrow set of inputs you actually support, and that narrowness is the security property.
Contract validation like this does not catch every exploit, and no schema will. What it closes is a specific and common category: the “we never checked what this endpoint accepts” bug. That category is where a surprising number of breaches begin.
Negative testing: prove the endpoint says no
Happy-path tests assert that good input produces good output. Negative tests assert that bad input produces a controlled refusal. The distinction matters because a refusal is a feature: a 400 with a clear error is your API defending its boundary. A 500 is your API losing control of it.
Build negative cases the same way every time. For each field, write down what it must reject: wrong type, missing when required, present when forbidden, too long, out of range, and the injection strings that fit its format. Then assert two things on the response. First, the status is a 4xx, usually 400 or 422. Second, the status is never a 5xx. A 500 means your hostile input reached code that was not ready for it, which is exactly the reachability an attacker wants. Our API security testing checklist has a field-by-field starting list you can adapt.
One rule keeps this honest: assert on behavior, not on error text. If you assert that the message reads “invalid loader,” a harmless refactor breaks your test and teaches the team to loosen it. Assert the status code, and where you can, assert that no side effect happened at all.
The injection classes worth a dedicated test
A few injection families show up often enough that each deserves standing test cases, not a one-time manual check. You do not need to be exhaustive here. You need one probing case per class so that a regression fails loudly. Tools that run automated API vulnerability detection can widen coverage later, but a handful of hand-written cases catches the obvious holes first.
- SQL injection: send
1); DROP TABLE datasets;--into any field that reaches a query. The endpoint should treat it as a literal value and answer 400, or return an empty result, and never surface a database error. - Template injection: send
{{ 7*7 }}and{{ config.__class__ }}into name and label fields. If the response ever contains49, a template engine evaluated your input, and that is remote code waiting to happen. - Insecure deserialization and remote-code loaders: send a
loaderofpickle://or a serialized object where a plain value belongs. This is the Hugging Face shape exactly. The endpoint should reject unknown loaders by allowlist, not try to be helpful. - Command injection: send
; idand$(id)into any field that might become a shell argument, such as a filename or a conversion option. A 200 that leaks a user id is a critical finding, not a curiosity.
Oversized, malformed, and content-type confusion
Not every hostile input is a clever string. Some are simply too big or shaped wrong, and these often break parsers before your validation logic even runs.
Send an oversized payload: a single field holding five megabytes of one character, or a JSON array with a million elements. A healthy API enforces a body-size limit and returns 413 rather than allocating memory until it falls over. Send malformed bodies too: truncated JSON, a trailing comma, or JSON nested a thousand levels deep to probe for stack exhaustion. The correct answer is a fast 400, not a hung worker.
Content-type confusion is the quiet one. Declare Content-Type: application/json but send XML, or declare application/xml and send a payload with an external entity to probe for XXE. Flip it the other way and send JSON as text/plain to see whether a lax parser accepts it anyway. Each mismatch tests whether your server trusts the header, trusts the body, or checks that the two agree. It should require agreement before it parses anything.
Why AI agents raise the stakes
Everything above was true before agents existed. Agents change the volume and the speed. A human attacker types one hostile request at a time. An AI agent generates and forwards payloads at machine speed, and it will happily construct inputs a person would never bother to try.
Three properties make this worse. Agents synthesize input, so they produce field values no human wrote and no test anticipated. Agents retry and chain calls, so a single poisoned upstream document can turn into thousands of hostile requests against your endpoint in seconds. And agents forward data they were told to trust, which is how a payload hidden in a dataset or a webhook becomes a real request to your API. The Hugging Face pattern, where “load this data” turns into “run this code,” is precisely the kind of instruction an agent will carry across a trust boundary without noticing. Our note on prompt injection for API teams goes deeper on that hand-off. The defense does not change; it just has to be automatic, because you cannot review agent traffic by hand.
Build the negative suite and run it in CI on every change
Turn the cases above into a suite that runs on every pull request. Here is a compact parameterized version in pytest that hits a staging endpoint and asserts a controlled refusal:
import httpx
import pytest
BASE = "https://staging.internal/v1"
HOSTILE_CONFIGS = [
{"loader": "pickle://s3/models/payload.pkl", "format": "auto"}, # remote-code loader
{"loader": "csv", "name": "{{ 7*7 }}"}, # template injection
{"loader": "csv", "name": "{{ config.__class__ }}"}, # object traversal
{"loader": "csv", "filter": "1); DROP TABLE datasets;--"}, # SQL injection
{"loader": "csv", "name": "A" * 5_000_000}, # oversized field
]
@pytest.mark.parametrize("config", HOSTILE_CONFIGS)
def test_dataset_config_is_refused(config):
r = httpx.post(f"{BASE}/datasets", json={"config": config}, timeout=10)
assert r.status_code in (400, 413, 422), r.text # a boundary that says no
assert r.status_code < 500, "5xx means the payload reached logic it should not"
assert "49" not in r.text, "template rendered: server-side template injection"
Wire it into CI so it gates merges. A minimal GitHub Actions job does the job:
name: api-abuse-tests
on: [push, pull_request]
jobs:
negative-input:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/negative_input.py -q
This is where a schema-first tool earns its place. In Apidog you design the endpoint against an OpenAPI contract, so every request and response is checked against that contract while you test. You can save negative scenarios right next to the happy-path ones: oversized fields, wrong types, and the injection strings above, each with an assertion that the status is a 4xx. Then you run the same scenarios in CI through the Apidog CLI, so a change that quietly loosens validation fails the build instead of shipping. If you want to try it, Download Apidog and add one negative scenario to an endpoint you already have.
Be clear about the boundary. Apidog is a design, test, mock, and documentation tool. It does not run a web application firewall, filter live traffic, or replace a SIEM, and contract validation during testing will not catch every exploit. What it does well is make the contract explicit and keep you honest about what an endpoint accepts, so the “we never checked” category stops being the thing that surprises you in production.
Frequently asked questions
What is the difference between negative testing and fuzzing? Negative testing sends a curated set of bad inputs you chose on purpose, one per failure you care about. Fuzzing sends large volumes of random or mutated inputs to find cases you did not think of. Start with negative tests because they are fast, deterministic, and easy to run in CI. Add fuzzing when you want breadth beyond your own imagination.
Should these tests run against production? No. Run them against staging or an isolated environment. Some cases, like the oversized payload or a command-injection probe, are designed to stress the system, and a few could mutate data if a bug exists. A dedicated test environment lets the tests be aggressive without any risk to real users.
Won’t a firewall or WAF catch this anyway? A WAF is useful defense in depth, but it is not a substitute for the application refusing bad input. Rules can be bypassed, and a WAF cannot know your business logic. The point of these tests is to prove the endpoint itself says no, so you are not relying on a filter you do not fully control.
How many negative cases are enough per endpoint? Aim for one case per field per failure class it can suffer: wrong type, out of range, too long, forbidden field, and any injection string that fits its format. That is usually a handful of cases per endpoint, not hundreds. Coverage of the classes matters more than raw count.
Does schema validation stop injection completely? No, and it should not be your only layer. A strict schema removes a large share of malformed and oversized input and blocks unexpected fields, but a value can be schema-valid and still be a SQL or template injection. Keep parameterized queries, safe deserialization, and output encoding in place, and use the schema to shrink the surface those layers have to defend.



