Content type confusion in authenticated agent API calls
Content type confusion can let authenticated agent API calls bypass intent. Test JSON, forms, multipart, and empty bodies against one schema.

Authenticated agent calls need one interpretation from the network edge to the action handler. If the gateway sees a harmless JSON request, the authorization layer sees one set of fields, and the handler sees a privileged form submission, the credential did its job and the API still failed.
This is not a niche concern reserved for old browser forms. Agents generate HTTP directly, retry aggressively, reuse examples from tool descriptions, and often operate with credentials that can change real systems. A request body is part of the authorization decision whenever it selects a target, amount, environment, command, or permission. You must make its media type, syntax, and schema unambiguous before you decide whether the caller may act.
An authenticated request still has to mean one thing
Authentication answers who presented a credential. Authorization answers whether that caller may perform an action. Neither answer tells you whether every component agreed on the action's arguments.
Consider an endpoint that changes a deployment target:
POST /v1/deployments/promote HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
{"environment":"staging","release":"2026.07.22"}
The authorization code may allow promotion to staging but deny production. That code is only sound if it receives the same environment value that the action handler uses. If a middleware layer reads JSON, a handler later consults form parameters, and both are allowed to populate one request object, you have created two sources of truth.
The failure does not require a broken cryptographic token. An agent with a legitimate session can send a body that one layer ignores and another layer honors. A compromised agent can do the same. The result is an authorization bypass expressed as input formatting.
RFC 9110 says that Content-Type indicates the media type of the associated representation and defines both the data format and how a recipient is meant to process it. That makes the header part of request semantics, not decoration. The same RFC also allows a recipient without Content-Type to assume octet-stream or inspect the data, which is useful for generic file handling but a bad default for protected action APIs.
For an action endpoint, establish this invariant:
Exactly one accepted media type maps the request bytes to exactly one validated command object. Every security decision and every side effect uses that object.
The endpoint may support more than one representation, but each representation needs its own contract and its own test suite. Do not treat several parsers as interchangeable conveniences.
A Content-Type header is not a schema
Content-Type: application/json does not mean “this is the request shape I expected.” It means the sender claims the body uses a JSON media type. You still need to decide whether that type is supported by this route, whether parameters are allowed, whether the body is syntactically valid, and whether the decoded value matches the operation contract.
A protected endpoint should make its allowed representation set deliberately small. Many command endpoints should accept JSON only. An upload endpoint may accept multipart only. An action with no arguments should accept no body at all. The wider the accepted set, the more parser paths you own.
OWASP's REST Security Cheat Sheet makes the practical recommendation plainly: document supported content types and reject unexpected or missing types, while allowing an omitted content type for a request whose content length is zero. It also warns that a body and its declared type must match to avoid producer and consumer misinterpretation.
That guidance needs one qualification for authenticated actions. Do not “match” a claimed type by sniffing the first character and choosing a parser. A body beginning with { is not permission to handle a request declared as form data as JSON. Sniffing turns a clear contract into an implementation guess.
A useful route contract looks like this:
| Route | Allowed request media type | Body rule |
|---|---|---|
POST /v1/deployments/promote | application/json | Required JSON object matching PromoteRequest |
POST /v1/artifacts | multipart/form-data | Required parts matching ArtifactUpload |
POST /v1/sessions/revoke | none | Must contain zero bytes |
Be exact about media-type parameters. If your JSON parser accepts application/json; charset=utf-8, document that and normalize parameters through one library. If it accepts only bare application/json, reject the parameter rather than letting the proxy and application differ. The choice matters less than applying it once.
Also separate the Accept response preference from the request's Content-Type. A client can ask for a JSON response while sending an invalid request body. Never let an Accept header expand what request formats an action endpoint will parse.
JSON needs rules beyond valid syntax
A JSON parser can successfully parse input that your API must still reject. Duplicate member names are the obvious example:
{"environment":"staging","environment":"production","release":"2026.07.22"}
RFC 8259 says object names should be unique and explains why: receivers do not agree on duplicate names. Many retain the last value, some fail, and others expose every pair. That is a documented interoperability problem, not a theoretical style preference.
Suppose an authorization middleware uses a parser that retains the first environment value, while a downstream decoder keeps the last. The middleware approves staging and the handler promotes production. You cannot repair that with better role names or another token claim. Reject the request before either component makes a decision.
Do the same work for values that look harmless in a loose language binding:
- Reject unknown object members for action requests unless you have a stated compatibility reason to retain them.
- Require the expected JSON type. A boolean is not a string that happens to say
true, and an integer identifier is not a floating point number. - Set a bounded body size before parsing. A schema validator cannot protect memory you already exhausted reading a huge body.
- Decide whether a field can be omitted,
null, or an empty string. Those are three different states. - Reject trailing data and parser extensions such as comments,
NaN, or unquoted names if your library offers them.
Do not authorize directly from a generic map. Decode into a request type with an explicit schema, perform semantic validation, then construct an internal command type that does not retain raw parser artifacts. A handler that receives PromoteCommand { environment, release } has less room to reinterpret input than one that receives a map, query collection, request object, and raw body.
Numbers deserve special care. JSON's grammar permits large numeric literals, but many runtimes decode numbers into a floating point representation unless you configure them otherwise. If a value identifies money, quotas, database records, or a signed payload, use a string format or an integer parser with a documented range. Do not let one layer round a number before another layer compares it.
Form bodies create hidden array and nesting rules
application/x-www-form-urlencoded looks simple because it resembles a query string. It is not simple once libraries begin assigning meaning to repeated names, bracket notation, plus signs, and empty values.
Take these bodies:
role=user&role=admin
role[]=user&role[]=admin
role[user]=1&role[admin]=1
role=user%26role%3Dadmin
Different frameworks can treat those as a last scalar, a first scalar, an array, an object, literal field names, or a parsing error. Some middleware parses forms for every request method. Some application frameworks merge query parameters and form parameters into a convenience object. That convenience object is where protected APIs lose track of what the caller actually sent.
OWASP's testing guidance for HTTP parameter pollution notes that behavior depends on interactions among the application, web server, WAF, and middleware. That is the exact reason to test raw repeated parameters rather than trusting one framework's parser documentation.
The popular recommendation to accept JSON and URL-encoded forms on every endpoint “for client compatibility” is usually wrong. It survives because it makes a demo client easy to write and because many frameworks enable it by default. It also doubles the representation contracts for every action, then quietly adds a third contract when query fields merge with the body.
If you must support a form endpoint, give it an endpoint-specific parser policy:
- Reject repeated names unless the schema defines that field as a list.
- Reject bracket syntax unless the schema defines its exact encoding and your parser implements it consistently.
- Keep query parameters separate from form fields. Do not allow either source to overwrite the other.
- Convert the parsed fields into the same typed internal command used by the JSON route only after validation.
- Test percent encoding,
+versus%20, blank values, missing=, and duplicate fields through the production request path.
Do not solve this by selecting “first wins” or “last wins.” That produces a deterministic answer inside one component while preserving disagreement elsewhere. A protected scalar field should appear once.
Multipart is an upload protocol, not flexible JSON
multipart/form-data has a legitimate job: it carries several independently headed parts, often with file content. RFC 7578 defines it for form values and requires a boundary parameter that separates parts. Each part can also bring its own headers and filename metadata.
That structure makes multipart a poor fallback representation for ordinary authenticated commands. It has more syntax, more size handling, more places for duplicate field names, and more opportunities for a gateway to inspect one part while the application chooses another.
A common bad design accepts a JSON metadata part alongside a file and then also accepts top-level form fields that can override the metadata:
Content-Disposition: form-data; name="metadata"
{"project":"alpha","visibility":"private"}
Content-Disposition: form-data; name="visibility"
public
One component may authorize based on metadata.visibility. Another may bind the later form part to the handler's visibility parameter. The request has two values for one security-sensitive property, expressed in two grammars.
Design multipart endpoints around named parts with distinct jobs. For example, accept exactly one file part and exactly one manifest part. Require manifest to be JSON with its own strict schema. Reject any part name not listed in the upload contract, reject duplicate singleton parts, set separate limits for total body size and file size, and decide whether part-level Content-Type values are required.
Do not trust a filename as a path, a MIME claim as a file classification, or a multipart parser's temporary-file behavior as a security control. Those are separate upload problems. The parser-confusion rule remains simpler: authorization inputs must come from one named, validated source. If manifest.project governs where a file lands, no other part, query parameter, or header may change that project.
When a command has no file, do not accept multipart. An extra accepted media type is an extra way to make two components disagree.
An empty body is a contract, not an absence of validation
Some authenticated actions need no arguments. Revoking the current session, rotating a server-generated nonce, or acknowledging a fixed event can use an empty request body. In those cases, make emptiness enforceable.
An endpoint with a no-body contract should reject all of the following:
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/json
Content-Length: 2
{}
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11
scope=other
POST /v1/sessions/revoke HTTP/1.1
Transfer-Encoding: chunked
0
The last example contains no content, but it still uses a framing mechanism your no-body contract may forbid. Whether you reject it depends on your HTTP stack, but decide and test it at the edge. Do not let a proxy pass framing that the application treats differently.
For a no-body route, enforce these rules before business logic:
- The request has no content bytes.
- The route does not accept
Content-Typeexcept where a compatibility rule explicitly allows it. - The route does not merge query parameters into the command unless each permitted query name appears in its own schema.
- The server records the action as argument-free, rather than logging a generic request object that later readers mistake for input.
RFC 9110 describes request content according to method semantics, and it does not grant a body universal meaning merely because a request uses POST. Your resource contract supplies that meaning.
The awkward case is a client library that always sends {}. Do not widen the endpoint merely to accommodate it. Fix the client or give it a separate, documented route. A body that currently has no effect often becomes an accidental input channel after a later handler change.
Validate before authorization and execute from the validated command
The safest request pipeline has one direction of travel. Raw bytes enter. A route selects one permitted parser. The parser produces a typed value. Validation produces a canonical command. Authorization evaluates that command. The executor receives the same command.
raw HTTP request
-> route and media-type check
-> bounded body read
-> one strict parser
-> schema and semantic validation
-> canonical command
-> authorization
-> execution and audit record
Do not reverse the middle two stages. Authorization often needs fields such as project ID, environment, recipient, or command mode, so teams are tempted to inspect loosely parsed input early. That creates a pre-authorization parser whose behavior must remain identical to the final decoder forever. Few systems maintain that promise.
The canonical command is a practical boundary, not a pattern for diagrams. It should contain only values the executor needs and should exclude raw body text, form collections, framework request objects, and aliases. If your executor receives target_environment, it should not later consult req.query.environment because the target was missing or inconvenient.
This also improves audit records. Log the authenticated principal, endpoint, accepted media type, a request digest, the canonical command fields that are safe to retain, the authorization decision, and the result. Logging raw request bodies by default creates a second problem because bodies can include credentials, uploaded files, and user data. A digest lets you correlate an event with preserved evidence without turning logs into a secret store.
Request signing requires the same discipline. If a client signs bytes but the server authorizes a normalized object, record both the signed representation rules and the canonicalization rules. If the client signs a canonical object, reject all alternate encodings before you verify the signature. Otherwise two byte sequences can carry the same business request, or one sequence can gain a different meaning after parsing.
Test the disagreement, not only the happy parser
Unit tests that deserialize one valid JSON fixture prove almost nothing about parser agreement. Your test target is the public request path: load balancer or reverse proxy, gateway, framework middleware, route handler, and any service that re-parses the body.
Build a compact negative corpus for each authenticated operation. The corpus should run in CI against a disposable environment and should assert both the response and the absence of side effects. A 400 response is not enough if a queue message, audit event, or partial file write already occurred.
Start with this shell harness. It deliberately sends raw bodies instead of relying on a generated client that refuses malformed inputs:
base=https://api.test.example
bearer='test-token'
send() {
name=$1
type=$2
body=$3
code=$(curl -sS -o "/tmp/${name}.out" -w '%{http_code}' \
-X POST "$base/v1/deployments/promote" \
-H "Authorization: Bearer $bearer" \
-H "Content-Type: $type" \
--data-binary "$body")
printf '%-28s %s\n' "$name" "$code"
}
send valid_json 'application/json' \
'{"environment":"staging","release":"2026.07.22"}'
send duplicate_json 'application/json' \
'{"environment":"staging","environment":"production","release":"2026.07.22"}'
send form_body 'application/x-www-form-urlencoded' \
'environment=production&release=2026.07.22'
send false_json 'application/json' \
'environment=production&release=2026.07.22'
The expected output shape should be one success and three client rejections:
valid_json 200
duplicate_json 400
form_body 415
false_json 400
Your actual status convention may return 422 for a syntactically valid request that fails the schema. Preserve the important distinction: a media-type mismatch must never reach a fallback parser, and a duplicate JSON member must never reach authorization.
Extend that corpus with cases that target boundaries between components:
| Case | What must happen |
|---|---|
Missing Content-Type with a nonempty body | Reject before parsing |
| JSON object with an unknown field | Reject or record a documented compatibility behavior |
| Repeated form scalar | Reject |
| Query value conflicts with JSON value | Reject or ignore query by route contract |
Multipart has two manifest parts | Reject |
No-body route receives {} | Reject |
Then inspect the audit trail. Each rejected input should have a trace that identifies the route and rejection class without logging sensitive request contents. Each accepted input should yield one canonical command. If logs show the gateway saw one target and the handler recorded another, you found a disagreement even if the test got a 2xx response.
Proxies and middleware are parsers too
Teams often point at the application parser and forget the components before it. Reverse proxies may normalize headers. API gateways may inspect JSON to apply a rule. WAFs may parse form data. Observability middleware may read and reconstruct a body. A framework may populate query, form, and JSON fields before the route handler runs.
OWASP's HTTP request smuggling guidance describes the larger version of this problem: intermediaries and backend servers can interpret request boundaries differently, especially around protocol translation and framing. Content-type confusion does not need request smuggling to be dangerous, but both failures come from allowing different layers to make incompatible parsing decisions.
Inventory every body reader in the action path. For each one, write down which media types it parses, whether it retains duplicate values, whether it decompresses content, whether it imposes a size limit, and whether it can rewrite the body. If nobody can answer those questions, the endpoint is not ready for agent credentials.
Keep the gateway's role narrow. It can enforce route-level body limits and block media types that a route never accepts. It can also reject malformed headers before the application. But do not use a gateway transformation to turn form data into JSON or to “clean” duplicate fields. The application must still reject ambiguity using the exact semantics it will execute.
Test HTTP versions and deployment paths that production actually uses. A request that behaves correctly against a local development server can change when an HTTP/2 client reaches a proxy that forwards HTTP/1.1 to the application. The point is not to build an attack research lab. The point is to make the production chain prove that it produces one command object for each accepted request.
Agent gateways should preserve the boundary
An agent gateway should keep credentials away from the model and preserve a record of the action, but it cannot make a permissive target API safe by itself. The gateway must send a representation the target route explicitly supports, and the target must validate that representation before it evaluates authority.
Sallyport's HTTP channel injects credentials while keeping API keys outside the agent, so an agent can request an action without receiving the secret itself. That is a useful credential boundary; pair it with endpoint contracts that reject ambiguous bodies, because protected credentials still authorize the request that reaches the API.
Give agents tools that reflect the contract instead of exposing a generic “make any HTTP request” action for sensitive systems. A promotion tool should accept typed environment and release arguments. Its implementation should serialize one JSON object, set one media type, and reject tool inputs that cannot meet the API schema. The receiving service must repeat the validation. Tool schemas reduce mistakes; they do not replace server-side distrust.
When an agent needs an upload, make that a separate tool with a named file and a named manifest. When it needs a no-argument action, provide no body field at all. These small constraints make the agent's intended request easier to inspect, approve, replay in a test environment, and audit later.
Do not approve a vague capability and then expect parsers to supply the missing precision. Make the endpoint accept one meaning, make the agent send that meaning, and reject every alternate spelling before a credential can authorize it.
FAQ
What is content type confusion in an API?
It is a disagreement between components about what an authenticated HTTP request means. A gateway, schema validator, framework parser, authorization check, and handler can each inspect the same bytes differently, which lets a request pass one control under one meaning and execute under another.
Why is content type confusion risky for AI agents?
Agents make the impact sharper because they can send many authenticated requests quickly and may receive broad action authority for a session. The API must treat each call as untrusted input even when a human approved the agent process that made it.
Does application/json guarantee a safe JSON request?
No. application/json only states the declared media type; it does not guarantee valid JSON, unique object members, correct field types, or an allowed request shape. Parse it strictly, reject duplicate names, then validate the resulting value against the endpoint schema.
Should a JSON API accept application/x-www-form-urlencoded requests?
Reject it unless the endpoint expressly accepts form data and has a separate, complete contract for it. Do not convert form fields into a JSON-shaped object before authorization, because repeated fields and bracket syntax can change meaning across libraries.
When should an authenticated API allow multipart/form-data?
Only if the endpoint needs file upload or an existing client protocol requires multipart. Treat multipart as its own parser and schema path, constrain part names and part headers, and do not quietly accept it as another spelling of a JSON action.
Can an authenticated POST endpoint require an empty body?
Yes, when the operation has no request representation. A request with Content-Length: 0 should have an explicit no-body contract and must reject content types, transfer encodings, and bytes that try to turn it into a different operation.
What status code should an API return for the wrong Content-Type?
A strict endpoint returns a client error before it evaluates authorization or runs business logic. Use 415 for an unsupported media type, 400 for malformed syntax, and 422 when valid syntax fails the endpoint schema, provided those distinctions fit your API conventions.
How do I test an API for parser disagreement?
Test raw requests through the same public edge, gateway, and application path used in production. For every protected operation, vary the media type, duplicate fields, repeated parameters, multipart part headers, body length, and content encoding, then assert that every invalid variant fails before the action layer.
Can an API gateway or WAF solve parser confusion by itself?
No. A WAF or API gateway can reject obvious bad input, but it is another parser in the chain and can introduce a different interpretation. The application that authorizes and executes the action must still parse and validate one canonical representation.
Should human approval replace per-request schema validation?
Approval should cover the agent process and the capability it receives, while the API still validates each request body as if it came from hostile code. Human approval is not a format validator and cannot repair ambiguity introduced after the agent sends the request.