DEV Community

Cover image for FastAPI accepts a response_model on streaming routes and silently ignores it
Jonathan Santilli
Jonathan Santilli

Posted on

FastAPI accepts a response_model on streaming routes and silently ignores it

It builds the model. It registers it in the OpenAPI schema components. It never applies it. Your private fields go out on the wire.

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

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


What breaks

On an ordinary route, response_model is how you declare the public shape of your output. FastAPI validates against it and drops fields that aren't in it. The documentation presents this as a way to keep server-only fields from reaching clients.

On a generator route serialized as JSON Lines or server-sent events, FastAPI accepts the same parameter and does not apply it.

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class PublicUser(BaseModel):
    username: str

class PrivateUser(PublicUser):
    api_key: str

@app.get("/users", response_model=PublicUser)
async def users():
    yield PrivateUser(username="alice", api_key="server-only-key")
Enter fullscreen mode Exit fullscreen mode

The route declares that clients receive only PublicUser. What they actually receive:

{"username": "alice", "api_key": "server-only-key"}
Enter fullscreen mode Exit fullscreen mode

The same thing happens when the generator is returned as an SSE stream.

The control is the important part. Swap the explicit model for the documented typed return annotation and the identical object is filtered correctly:

@app.get("/users")
async def users() -> AsyncIterable[PublicUser]:
    yield PrivateUser(username="alice", api_key="server-only-key")
Enter fullscreen mode Exit fullscreen mode
{"username": "alice"}
Enter fullscreen mode Exit fullscreen mode

So stream filtering works. It just doesn't run when you use the explicit parameter.


Where it is

In fastapi/routing.py. Three pieces have to line up.

Stream-item inference is gated on the parameter being absent. Line 1081 only runs the inference when response_model is still the internal default:

if isinstance(response_model, DefaultPlaceholder):
    ...
    route.stream_item_type = stream_item      # L1098
Enter fullscreen mode Exit fullscreen mode

Pass an explicit model and the whole block is skipped.

So the stream-item field is never built. The route builds a response_field from your model, then line 1123 leaves the other one empty:

route.stream_item_field = None
Enter fullscreen mode Exit fullscreen mode

And the stream serializer only ever looks at that second field. _serialize_data checks stream_item_field; when it's None the item goes out through a bare encoder with no filtering of any kind:

def _serialize_data(data: Any) -> bytes:
    if stream_item_field:
        ...                              # validate + filter
    else:
        data = jsonable_encoder(data)    # L517 — no model, no filtering
        return json.dumps(data).encode("utf-8")
Enter fullscreen mode Exit fullscreen mode

The chain in one line: explicit model → normal field built → stream inference skipped → stream field stays None → serializer falls through → nothing is filtered.


The tell that this is a missed branch

The generated OpenAPI for that route is incoherent in a way nobody designs on purpose.

"content": { "application/jsonl": { "itemSchema": {} } }
Enter fullscreen mode Exit fullscreen mode

An empty item schema. Meanwhile PublicUser is registered in components.schemas — and referenced nowhere. FastAPI built the model object, filed it, and then never used it.

That combination is what a skipped code path leaves behind. A deliberate decision to not support response_model on streams would look like an error at startup, or a documented note. It would not look like a dangling schema component and an empty item schema.

A correction to my own report. The advisory I filed claimed the model was "advertised in OpenAPI" while not being applied. That was wrong — the item schema is empty, so no client is told the stream is filtered. I got it wrong; the empty schema is still evidence, just of something different.


Is it a vulnerability or a bug?

Why the framework is at fault

Silent accept-and-ignore. FastAPI takes the parameter, constructs a field from it, puts the model in the schema components, and then never consults any of it on this code path. It neither honours the configuration nor rejects it. There is no warning at startup, no error, and nothing in the JSONL or SSE documentation saying the parameter is inert here.

The combination is untested. No test in FastAPI's suite pairs an explicit response_model with a generator route. Every streaming test drives the stream through a return annotation — the branch that works — and every test model in those files has only public fields, so field filtering is never asserted in either direction. The failing case was never in anyone's field of view.

Three structurally identical bugs were accepted and fixed as ordinary bugs within a month of each other, all in this same code path:

Fixed What was ignored
PR #15937 status_code ignored for SSE and JSONL — while OpenAPI documented it
PR #15093 response_model_* params ignored for Iterable returns
PR #15077 stream item type lost through include_router()

The first one is the same defect as this, one field over. It was fixed.

Why it may be only a bug

It's the wrong knob. FastAPI types stream items through the return annotation, and that mechanism works correctly. response_model describes a whole response body, which doesn't map cleanly onto a multi-item stream.

The docs point elsewhere. The streaming tutorials use typed return annotations throughout, and the custom response docs state that returning a Response directly means the data isn't converted "even if you declare a response_model."

Nobody was misled by the schema. As above, the item schema is empty. A client reading the OpenAPI was never told the stream was filtered.

You still have to yield the private object. The leak requires an application that constructs and yields a PrivateUser from a route it declared as public.

Where I land

An ordinary bug, correctly rated low severity — and I'd argue it should be framed that way rather than as information disclosure. The defensible complaint is not "FastAPI leaked my data." It is that a security-relevant filter can be configured, accepted, and silently discarded, with the failure visible only if you inspect the response body.

The right fix is the one FastAPI already applied to status_code: make the declared value work, or reject the combination loudly. Silently accepting configuration you don't honour is the actual defect.


What you can do today

  • Type your generators with return annotations, not response_model: -> AsyncIterable[PublicUser] for JSONL, and the documented typed event iterable for SSE. This is the supported path and it filters correctly.
  • Construct the public object explicitly before yielding it, rather than relying on subclass filtering.
  • Don't rely on response_model alone to protect stream output. Until this is fixed or rejected, treat it as having no effect on a streaming route.

The fix

Either apply the explicit model to each yielded item, or refuse the configuration at startup with a clear message. Not both, and not neither.

Regression coverage should include sync and async generators, JSONL and SSE, a subclass carrying extra fields, the generated OpenAPI, and the precedence rule when a return annotation and an explicit model are both present.


Status

Date Event
2026-07-26 Reported privately as GHSA-64wh-7wq2-pw5m, severity low, CWE-200 / CWE-693
2026-07-28 Three adjacent bugs in the same code path fixed as ordinary bugs (#15937, #15093, #15077)
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)