The wrapper exists so the value never appears in output. Pydantic masks it. str() masks it. FastAPI reaches past both and returns the real thing.
| Affects | FastAPI 0.140.0 → 0.141.1 (latest at time of writing) |
| Status | Unfixed |
| Reported | 2026-07-26 |
| Declined | 2026-08-11 |
This is the first of five write-ups covering findings I reported privately to the FastAPI project. All five were closed without publication. Four of them, including this one, still reproduce on the latest release.
What breaks
Pydantic gives you wrappers for values that should never be printed.
Secret[T] is the generic one, and subclassing it is Pydantic's documented way to define your own secret type. Everything about it is built so the value stays hidden: str() on it returns **********, and Pydantic's own serializer returns "**********".
Pass one through FastAPI's response encoder and you get the value itself, wrapped in the private attribute it was being stored in.
from fastapi import FastAPI
from pydantic import Secret
class Token(Secret[str]): # Pydantic's documented custom-secret pattern
pass
app = FastAPI()
TOKEN = Token("server-only-canary")
@app.get("/leak")
async def leak():
return TOKEN
@app.get("/leak-nested")
async def leak_nested():
return {"token": TOKEN}
GET /leak {"_secret_value":"server-only-canary"}
GET /leak-nested {"token":{"_secret_value":"server-only-canary"}}
The controls matter, because they show this is not Pydantic failing to mask. The exact same object, three other ways:
str(TOKEN) '**********'
TypeAdapter(Token).dump_python(TOKEN, ...) '**********'
same route with response_model=Token '**********'
Only the encoder leaks.
Where it is
The module keeps an explicit map of types it knows how to encode safely. That map registers SecretStr and SecretBytes, both to str — which is what produces the mask. It has no entry for Secret.
ENCODERS_BY_TYPE = {
...
SecretBytes: str,
SecretStr: str, # Secret is not here
...
}
With no registered encoder, the value falls through to the generic path for objects FastAPI doesn't recognize, which tries to turn the object into a dictionary and then reads its attributes directly:
Token(REAL_SECRET)
→ no matching encoder in ENCODERS_BY_TYPE
→ dict(Token) raises
→ vars(Token) → {"_secret_value": REAL_SECRET}
→ that dict is recursively encoded into the response
There is a detail here that explains why the existing entries don't help. SecretStr is not a subclass of Secret — the two descend separately from a private base class:
SecretStr → _SecretField → _SecretBase
Secret → _SecretBase
They're siblings, not parent and child. So the encoder map's isinstance pass, built from the SecretStr entry, structurally cannot catch a Secret. Covering it required an explicit addition, and it never got one.
When it actually fires
Three conditions, and being precise about them matters more than making the finding sound big:
-
A subclass of
Secret, not a bareSecret[str]("x"). The bare parametrized form raises aValueErrorinstead of leaking, becausevars()on it also carries__orig_class__, which blows up the recursive encode. Subclassing is the documented pattern, so this is the normal case, not the exotic one. - Reaching the encoder without Pydantic serialization — returned from a route with no response model, or nested inside a plain dict or list.
-
Not a model field. A
Secretdeclared as a field on aBaseModelis serialized by Pydantic and masked correctly.
So the everyday path is safe. What leaks is the settings-object or plain-container shape — returning a config holder, or a dict you assembled by hand, from a route you never bothered to type.
Is it a vulnerability or a bug?
Both cases deserve to be on the page. Here is the honest version of each.
Why the framework is at fault
It cannot have been a decision. FastAPI's encoder map in its current form dates to the Pydantic v2 support work on 2023-07-07
(PR #9816). Pydantic's generic Secret type was added in PR #8519, merged 2024-02-09, and shipped in v2.7.0 on 2024-04-11. That is a nine-month gap. Nobody chooses to exclude a type that doesn't exist yet, and git log -S "Secret[" -- fastapi/ comes back empty, so the map was never revisited.
They already know this fallback leaks private state. The same file carries a hardcoded filter that strips _sa-prefixed keys, with this docstring:
Exclude from the output any fields that start with the name
_sa.This is mainly a hack for compatibility with SQLAlchemy objects, they store internal
SQLAlchemy-specific state in attributes named with_sa, and those objects can't
(and shouldn't be) serialized to JSON.
The identical class of leak — private attributes escaping through the vars() fallback — was recognized and patched by hand for one library. _secret_value never got the same treatment.
It doesn't fail to mask — it unmasks. Reaching into the private storage of a confidentiality wrapper produces output strictly worse than an error would. A crash would have been safer than what it does.
Nothing is tested. The encoder test suite contains zero occurrences of Secret, SecretStr, or SecretBytes across its 345 lines. Even the masking that does work is unverified.
Why it may be only a bug
Untyped routes promise nothing. FastAPI documents that returning a value without a response model gives you best-effort encoding, with no field filtering. You opted out of the typed path.
The fallback is explicitly best-effort. The dict()/vars() route exists to make a reasonable attempt at objects FastAPI has no specific support for. Handing it an unsupported type and getting its attributes back is arguably the documented behavior, working as designed.
The preconditions are narrow. Subclass, plus untyped route, plus raw or plain-container return. The common patterns are all safe.
Severity is genuinely low. No remote trigger, no privilege escalation. It needs an application that already routes a secret somewhere it shouldn't.
Where I land
A bug with a security consequence. Whether that clears any particular project's bar for a published advisory is a policy question, and reasonable people set that bar differently.
But no reading of "working as intended" covers a framework printing the contents of a masking primitive it already half-supports — especially one whose own code contains a hand-written filter for exactly the same leak in a different library.
What you can do today
In order of preference:
- Declare a response model on any route that could return a secret-bearing object. This is the real fix at the application level, and it masks correctly.
- Keep secrets as model fields rather than returning bare wrappers or hand-built dicts. Pydantic then does the serializing.
- Register the encoder yourself if you want belt and braces. Both lines are needed — the second rebuilds the lookup table that the first one feeds:
import fastapi.encoders as enc
from fastapi.encoders import ENCODERS_BY_TYPE
from pydantic import Secret
ENCODERS_BY_TYPE[Secret] = str
enc.encoders_by_class_tuples = enc.generate_encoders_by_class_tuples(ENCODERS_BY_TYPE)
That is a monkeypatch on module internals, so pin your FastAPI version if you rely on it.
The fix
One line, in the map that already handles the two sibling types:
ENCODERS_BY_TYPE = {
...
Secret: str, # str(Secret) already returns '**********'
SecretBytes: str,
SecretStr: str,
...
}
I verified that this masks a direct return, a value nested in a dict, a value nested in a list, and Secret[int] — while leaving SecretStr and SecretBytes behavior untouched, since they carry their own entries and are not Secret subclasses. It works because str() on a secret already returns the mask, which is the exact mechanism the two existing entries rely on.
Status
| Date | Event |
|---|---|
| 2023-07-07 | FastAPI's encoder map is written, registering SecretStr and SecretBytes — commit 0976185
|
| 2024-02-09 | Pydantic adds the generic Secret base type — PR #8519
|
| 2024-04-11 | Ships in Pydantic v2.7.0. FastAPI's map is not updated |
| 2026-07-26 | Reported privately as GHSA-2w3m-5f3g-h9r8, severity low, CWE-200 / CWE-213 |
| 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 you can follow. The report contained the reproduction above, the controls, and the one-line fix.
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)