# Content type confusion in authenticated agent API calls

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:

```http
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:

```json
{"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:

```text
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:

1. Reject repeated names unless the schema defines that field as a list.
2. Reject bracket syntax unless the schema defines its exact encoding and your parser implements it consistently.
3. Keep query parameters separate from form fields. Do not allow either source to overwrite the other.
4. Convert the parsed fields into the same typed internal command used by the JSON route only after validation.
5. 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:

```text
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:

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/json
Content-Length: 2

{}
```

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11

scope=other
```

```http
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-Type` except 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.

```text
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:

```bash
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:

```text
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.
