DEV Community

Cover image for A FastAPI security guarantee that isn't true
Jonathan Santilli
Jonathan Santilli

Posted on

A FastAPI security guarantee that isn't true

The docstring says a body without a Content-Type header "will not be parsed as JSON." For one Pydantic type, it is.

Affects FastAPI 0.140.0 → 0.141.1 (latest at time of writing)
Status Unfixed
Reported 2026-07-26
Declined 2026-08-11

Third of five write-ups on findings reported privately to FastAPI and closed without publication.


The background you need

In 2021 FastAPI shipped a security fix for CVE-2021-32677. The project's own release notes describe the vulnerability:

In versions lower than 0.65.2, FastAPI would try to read the request payload as JSON even if the content-type header sent was not set to application/json or a compatible JSON media type. [...] But requests with content type text/plain are exempt from CORS preflights, for being considered Simple requests. So, the browser would execute them right away including cookies, and the text content could be a JSON string that would be parsed and accepted by the FastAPI application.

FastAPI assigned that a CVE, credited the reporter, and fixed it. So the project has already ruled that JSON being extracted from a request whose content type is not JSON is FastAPI's problem, not the application's.

In February 2026 that protection was formalised as a configurable feature, strict_content_type, shipped under the project's security commit prefix, with its own documentation page describing the threat model — a local or internal app, no authentication, a malicious page using fetch() with a Blob body to avoid a CORS preflight.


What breaks

Declare a request body as Pydantic's Json[T] and the protection stops applying.

from fastapi import FastAPI
from pydantic import BaseModel, Json

app = FastAPI()
actions: list[str] = []

class Action(BaseModel):
    command: str

@app.post("/json-wrapper")
async def json_wrapper(action: Json[Action]):
    actions.append(action.command)
    return {"command": action.command}

@app.post("/ordinary")
async def ordinary(action: Action):
    actions.append(action.command)
    return {"command": action.command}
Enter fullscreen mode Exit fullscreen mode

Send the identical bytes {"command":"transfer"} to both routes across three content types. The results are exact inverses:

Body declaration no Content-Type text/plain application/json
Json[Action] 200 — executes 200 — executes 422
Action 422 422 200

The Json[Action] route runs on precisely the preflight-free request shapes the protection exists to stop, and rejects properly-typed JSON.


Where it is

In fastapi/routing.py. When the content type is missing or isn't a JSON media type, FastAPI declines to call its own JSON parser — and then hands the raw bytes onward:

content_type_value = request.headers.get("content-type")     # L436
if not content_type_value:
    if not actual_strict_content_type:
        json_body = await request.json()
else:
    ...                       # only application/json or */*+json get parsed
if json_body != Undefined:
    body = json_body
else:
    body = body_bytes                                        # L450
Enter fullscreen mode Exit fullscreen mode

Nothing records that JSON interpretation was refused. The bytes go into validation carrying no memory of the decision, and Json[T] — whose entire meaning is "parse these bytes as JSON" — does exactly that.

The decision is made at one layer and undone at the next, inside a pipeline FastAPI owns end to end.


The guarantee in writing

This is the part that moves it beyond an inferred contract. FastAPI's own API documentation for the parameter states:

Enable strict checking for request Content-Type headers.

When True (the default), requests with a body that do not include a Content-Type header will not be parsed as JSON.

For Json[T], the body is parsed as JSON. Not by Starlette, by Pydantic — but the sentence doesn't distinguish, and neither does an attacker.


How it got here

The history is unusually legible.

The raw-bytes fallback on line 450 is not new. git blame traces it back to PR #2118 — the 2021 fix for CVE-2021-32677 itself. The 2026 strict_content_type feature added two lines above it and left the fallback untouched.

So a new security control was bolted onto a four-year-old escape hatch without the hatch being re-examined.

The test suite tells the same story. All three strict_content_type test files declare exactly one body type:

async def app_default_post(data: dict): ...
async def app_lax_post(data: dict): ...
Enter fullscreen mode Exit fullscreen mode

Every route in every test, data: dict. Meanwhile Json[T] is a supported type with its own test file — covering Form(), Query(), Header() and Cookie() positions, and never a request body.

The body-type space was never enumerated when the protection was designed.


Is it a vulnerability or a bug?

Why the framework is at fault

A written guarantee is false. Not a reasonable inference from the docs — the actual docstring for the actual parameter. That's the strongest single fact here.

FastAPI has already ruled on this mechanism. CVE-2021-32677 assigned exactly this behaviour to FastAPI, with a CVE and a credited reporter. Json[T] reaches the same end state by a different route.

FastAPI owns the integration. The whole value proposition of the framework is the Pydantic integration. "Starlette refused to parse it but Pydantic did" is not a boundary an application author can see or reason about.

The gap is an unconsidered case, not a decision. One body type in the tests, and Json[T] tested only in non-body positions. Nothing in the code comments or the docs acknowledges the fallback as security-relevant.

Why it may be only a bug — and this side is strong

A top-level Json[T] body genuinely is a bytes body. I measured Annotated[bytes, Body()] and it behaves identically: 200 with no content type, 200 with text/plain, 422 with application/json. Accepting a raw byte body without a content type is intentional, longstanding, documented behaviour. Json[T] rides that path and then re-parses. FastAPI never treated the request as JSON; the application asked for bytes and chose a type that reinterprets them.

It's non-idiomatic. You'd write action: Action. Nobody reaches for action: Json[Action] as a request body on purpose — and such a route rejects real application/json clients with a 422, so the author's own testing would surface it immediately.

The documented threat model is narrow. The feature's own page scopes it to local or internal apps with no authentication, and says plainly that for an app on the open internet "this attack / risk doesn't apply to you." My own proof-of-concept sends no credentials, which is consistent with that scope but also limits what it demonstrates.

Where I land

A real gap in a documented security control, and a weak severity claim. Those can both be true.

The honest framing isn't "FastAPI has a CSRF vulnerability." It's that the guarantee as written is broader than the guarantee as implemented, and one documented Pydantic type falls in the space between. The fix is either to narrow the docstring or to make the content-type decision bind before a top-level Json[T] is validated — a property of the request rather than of the field type.


What you can do today

If you're relying on strict_content_type as a barrier for a local or internal service:

  • Don't use top-level Json[T] on privileged state-changing endpoints. Use the model directly.
  • Reject unacceptable media types in middleware, before body validation runs, if you need the guarantee to hold regardless of field type.
  • Add real authentication and Origin/CSRF checks where you can. Content type alone was never a complete CSRF design, and FastAPI's own documentation says so.

The fix

When strict content-type checking refuses JSON interpretation, a type whose meaning is "parse this as JSON" should not be able to undo that during validation. Either enforce an accepted JSON media type before validating a top-level Json[T], or carry the refusal forward in a form validation can see.

Failing that, narrow the docstring so it describes what actually happens.

Regression coverage should include a missing content type, text/plain, accepted JSON types including structured +json, legitimate non-JSON byte bodies, and applications running with strict_content_type=False.


Status

Date Event
2021-06-09 CVE-2021-32677 — content-type CSRF fixed, reporter credited
2026-02-23 strict_content_type shipped as a security feature — PR #14978
2026-07-26 Reported privately as GHSA-7mw5-87j8-54ww, severity medium, CWE-352 / CWE-693
2026-08-11 Closed without publication, submission.accepted: false
2026-08-12 Re-verified against 0.141.1. Still present. No fix

The advisory is private, so that ID is citable but not a link a you can follow.


Verified by reading FastAPI's source at tag 0.141.1 and running the reproduction on 2026-08-12. Code links are pinned to that tag rather than master, so the line numbers stay valid.

Top comments (0)