DEV Community

Cover image for FastAPI prints the contents of Pydantic's Secret[T]
Jonathan Santilli
Jonathan Santilli

Posted on

FastAPI prints the contents of Pydantic's Secret[T]

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}
Enter fullscreen mode Exit fullscreen mode
GET /leak          {"_secret_value":"server-only-canary"}
GET /leak-nested   {"token":{"_secret_value":"server-only-canary"}}
Enter fullscreen mode Exit fullscreen mode

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        '**********'
Enter fullscreen mode Exit fullscreen mode

Only the encoder leaks.


Where it is

In fastapi/encoders.py.

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
    ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 bare Secret[str]("x"). The bare parametrized form raises a ValueError instead of leaking, because vars() 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 Secret declared as a field on a BaseModel is 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:

  1. 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.
  2. Keep secrets as model fields rather than returning bare wrappers or hand-built dicts. Pydantic then does the serializing.
  3. 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)
Enter fullscreen mode Exit fullscreen mode

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,
    ...
}
Enter fullscreen mode Exit fullscreen mode

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 SecretBytescommit 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)