Why a FastAPI endpoint happily sends back fields you never meant to expose, and the one line that filters them out.
The endpoint looked harmless. Fetch a user, return the user.
@app.get("/users/{user_id}")
async def get_user(user_id: int):
user = await db.get_user(user_id)
return user
It worked. It returned the user's name, email, everything the frontend needed. Nobody looked twice.
Weeks later, someone opened the browser network tab while debugging an unrelated issue and went quiet. The JSON response for that endpoint had a field none of us wanted anyone to see.
{
"id": 42,
"username": "anna",
"email": "anna@example.com",
"password_hash": "$2b$12$Q9....",
"is_admin": true,
"internal_notes": "flagged for review"
}
The password hash. The admin flag. An internal notes field. Every column on the user row, shipped straight to the client, on every single request, for weeks.
Nothing had errored. The endpoint did exactly what I told it to. The problem was that I had told it to return "the user," and "the user" turned out to mean all of it.
Here is why FastAPI did this, why it is the default, and the one line that would have stopped it.
What "Return the User" Actually Sent
The mistake is assuming FastAPI only sends back the fields you have in mind. It does not. By default, it serializes whatever object you return, in full.
My db.get_user() returned a database object with every column on it, including password_hash, is_admin, and internal_notes. When I returned that object, FastAPI turned the whole thing into JSON. Every field. It had no way to know some of those fields were secret, because I never told it which fields were allowed out.
The endpoint was not leaking by accident. It was doing precisely what "return this object" means: send the object. All of it.
This is the trap. "Return the user" sounds like it means "return the public view of the user." To FastAPI, it means "serialize this entire object and send it," and the entire object includes the things you would never put in a response on purpose.
The Feature I Had Skipped: response_model
FastAPI has a built-in answer to exactly this, and I had simply never used it. You declare a response model: a separate schema describing what the response is allowed to contain. FastAPI then filters the output down to only those fields.
The official docs state the behavior directly:
FastAPI will take care of filtering out all the data that is not declared in the output model.
The docs even use this exact password scenario as the motivating example. They show the danger of reusing one model for input and output:
Now, whenever a browser is creating a user with a password, the API will return the same password in the response.
And then the fix: one model for input (with the password), a different model for output (without it).
from pydantic import BaseModel, EmailStr
class UserIn(BaseModel):
username: str
password: str
email: EmailStr
full_name: str | None = None
class UserOut(BaseModel): # note: no password, no is_admin, no internal_notes
username: str
email: EmailStr
full_name: str | None = None
@app.post("/user/", response_model=UserOut)
async def create_user(user: UserIn):
return user
The docs explain why this is safe even though the function returns the full input object:
even though our path operation function is returning the same input user that contains the password … we declared the response_model to be our model UserOut, that doesn't include the password.
That is the whole mechanism. You return whatever you have. FastAPI validates it against UserOut, keeps only the fields UserOut declares, and drops everything else before it reaches the client. The password hash never leaves the server, because UserOut does not mention it.
Applied to my leaking endpoint, the fix was one decorator argument:
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int):
user = await db.get_user(user_id)
return user # still the full object; FastAPI filters it to UserOut
Same code, same return value. But now the response contained only username, email, and full_name. The hash, the admin flag, and the internal notes were filtered out, every time, automatically.
Why Filtering Is Opt-In, Not Automatic
Here is the part worth sitting with, because it is the root of the danger.
FastAPI does not filter by default. Filtering only happens when you declare a response model (or a response return type). No declaration means no filter, which means the full object goes out.
That design makes sense once you see it: FastAPI cannot guess which of your fields are public. Only you know that email is fine and password_hash is not. So the safe output is something you have to describe, and until you describe it, FastAPI does the literal thing and sends what you returned.
The consequence is that forgetting the response model is not a loud error. It is a silent over-share. The endpoint works, the tests pass, the frontend gets its data plus a pile of extra fields it quietly ignores. Nobody notices the extra fields until someone reads the raw response, because nothing is broken from the client's point of view. It is just handing out more than it should.
That is the signature of this whole class of bug. The failure is not a crash. It is the absence of a restriction you never added.
Two Ways to Declare It, and the One That Bit Me
You can specify the allowed output in two places, and knowing both saves confusion.
-
On the decorator with
response_model=UserOut. This is a parameter of the route decorator (@app.get,@app.post), not of your function. -
As the function's return type with
-> UserOut. FastAPI reads the annotation and filters to it, and your editor and type checker get the benefit too.
If you use both, the docs are explicit about precedence:
If you declare both a return type and a response_model, the response_model will take priority and be used by FastAPI.
The gotcha that got me: I had typed my endpoint as -> User, where User was my database model, thinking I was being careful with type hints. But User had every field on it, so annotating with it filtered nothing.
The type hint looked responsible and did nothing for safety, because the model I pointed at was the very model with the secrets in it. A response model only protects you if the model you name actually omits the sensitive fields.
A Bonus the Response Model Unlocks: Trimming Unset Fields
Once you are declaring response models, one related option is worth knowing, because it cleans up responses nicely. response_model_exclude_unset=True tells FastAPI to leave out fields that were never actually set, so defaults do not clutter the output:
@app.get("/items/{item_id}", response_model=Item, response_model_exclude_unset=True)
async def read_item(item_id: str):
return items[item_id]
The docs describe the effect:
those default values won't be included in the response, only the values actually set.
So an item stored as just {"name": "Foo", "price": 50.2} returns exactly those two fields, instead of padding the response with every optional field at its default.
There are siblings for this too, response_model_exclude_defaults and response_model_exclude_none, for when you want to strip defaults or nulls specifically. Minor features, but they make your responses lean once the response model is in place.
The Rule I Take Away
Compressed to one line:
FastAPI serializes whatever you return, in full, unless you declare a response model. Returning a database object with no response model sends every field, secrets included. Declare a separate output schema with
response_model=(or a return type) that lists only the fields allowed out, and FastAPI filters the rest away.
The deeper lesson, the one that outlasts this specific framework, is to treat your output shape as something you declare on purpose, never something you let fall out of your internal objects.
Your database model and your API response are two different things that happen to overlap:
- The database model is for storage and has everything.
- The response is a public contract and should have only what the public may see.
Collapsing the two, returning the storage object directly as the response, is how private fields escape. Keep an explicit output schema and the leak becomes impossible, because the secret fields are simply not in the schema.
And the usual sting applies. This never shows up as a failure in testing, because the client gets its data and works fine. The extra fields ride along silently, and the only way to notice is to read the raw response and ask "why is that in here?" Usually long after it has been going out to everyone.
How to Catch It Before It Catches You
Keep these four habits:
-
Never return a raw database object from an endpoint without a response model. If you return an ORM or database object and have not declared what is allowed out, you are shipping every column. Declare a
response_modelthat lists only public fields. - Make output models separate from input and database models. One schema for what comes in, a different schema for what goes out, and neither is your storage model. The output schema should physically not contain the secret fields, so there is nothing to leak.
-
Do not trust a return type that points at your database model.
-> Userfilters toUser, and ifUserhas the password on it, you have filtered nothing. The protection comes from the named model omitting the secrets, not from having an annotation. -
Read your own raw JSON responses. Open the network tab or
curlthe endpoint and look at every field in the response. The leak is invisible from the frontend, which ignores extra fields. Reading the raw response is the only reliable way to spot an over-share.
I added one response_model=UserOut to the leaking endpoint, reloaded, and watched the password hash, the admin flag, and the internal notes vanish from the response. The code still returned the full user object. FastAPI had simply, finally, been told what was allowed to leave.
The 30-Second Takeaway
-
By default, FastAPI serializes whatever you return, in full. Return a database object and every field goes to the client, including
password_hash,is_admin, or anything else on the row. - Filtering is opt-in. FastAPI only limits the output when you declare a response model. No response model means no filter, so the whole object is sent. This is a silent over-share, not an error.
-
The fix: declare a separate output schema and pass it as
response_model=UserOuton the decorator (or as a-> UserOutreturn type). FastAPI filters out everything not declared in the output model. - Keep input, output, and database models separate. The output model should not contain the secret fields at all, so there is nothing to leak. The docs use this exact password example.
-
A return type pointing at your DB model protects nothing.
-> Userfilters toUser; ifUserholds the password, you have filtered nothing. Safety comes from the model omitting the secrets. -
Bonus:
response_model_exclude_unset=True(and_exclude_defaults,_exclude_none) trims unset or default fields from responses once a response model is declared. - It is invisible in testing: the frontend ignores extra fields and works fine. Read the raw JSON response to spot what is actually being sent.
Your Turn
Open your API and find every endpoint that returns a database object. For each one, check: is there a response model, and does it omit every sensitive field? Any endpoint returning a raw ORM object with no response model is shipping every column to the client right now.
Have you ever found something in a raw API response that shouldn't have been there? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
Top comments (4)
Filter on the way out, with a response model that lists the fields you allow. Filtering on input does nothing for what the endpoint returns.
Exactly. Validating input is a separate job. What leaves the server needs its own list of allowed fields.
The response model is the right fix, and there is a structural complement worth adding: make the sensitive fields unreachable from the object the endpoint touches at all. password_hash and internal_notes were dangerous precisely because they lived on the same row the query returned, so "return the user" carried them. Keeping credentials in a separate table, loaded only by the auth flow, means a future endpoint that forgets the response model still cannot leak them - there is nothing to serialize. The allowlist protects the endpoints you remember to write it on; separation protects the ones nobody has written yet. The last gap is drift: a contract test that asserts the exact response key set per endpoint fails the day a well-meaning change adds a field, instead of someone noticing in a network tab weeks later.
@anas_issath Agreed. Validation fails loudly, with an error. A leaked column ships in a normal response, and nobody notices until someone reads it.