Validation error disclosure
FastAPI returns 422 with a structured body when request validation fails. Each entry
carries type, loc, msg and input — and input is the value that failed.
What “the value that failed” means depends on the error, and that is the part worth knowing.
Observed
Section titled “Observed”One model — email, password, age — and two requests that differ only in how they
are wrong.
A missing field:
POST {"email": "a@b.c", "password": "hunter2"} // age omitted
{"detail": [{ "type": "missing", "loc": ["body", "age"], "msg": "Field required", "input": {"email": "a@b.c", "password": "hunter2"}}]}A type error:
POST {"email": "a@b.c", "password": "hunter2", "age": "x"}
{"detail": [{ "type": "int_parsing", "loc": ["body", "age"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "x"}]}The password comes back in the first and not the second. For a missing error the
thing that failed the check is the object — it is the object that lacks the field —
so input is the whole submitted body. For a field-level error the thing that failed is
that field’s value alone.
{"detail":[{"type":"missing","loc":["body","age"],"msg":"Field required","input":{"email":"a@b.c","password":"hunter2"}}]}A 422 for a missing field. Note what `input` holds — for type `missing` it is the whole submitted object, because the value that failed the check is the parent, so every other field the client sent comes back in the response. The password was never validated and is echoed anyway.
Read in: reproduced on FastAPI 0.141.0 / Pydantic 2.13.4{"detail":[{"type":"int_parsing","loc":["body","age"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"not-a-number"}]}The same endpoint and the same body, failing on a type instead. Here `input` is only the offending field's value, so nothing else leaks — which is why this is easy to test and conclude the wrong thing from.
Read in: reproduced on FastAPI 0.141.0 / Pydantic 2.13.4Why this is rated low, and when it is not
Section titled “Why this is rated low, and when it is not”The response goes back to whoever made the request, so in the ordinary case a client is
being shown its own submission. That is not a disclosure to a third party, and it is
why this is low rather than something louder.
It stops being harmless when the response is stored or forwarded. A 422 body captured in an error-tracking service, written to an access log that records response bodies, surfaced in an aggregation dashboard, or echoed into a support ticket now contains a plaintext credential in a system that was never scoped to hold one.
The version that genuinely matters: a proxy or gateway that logs response bodies for non-2xx statuses, which is a common default, will collect passwords from every signup form that mis-validates.
from fastapi import FastAPI, Request, statusfrom fastapi.exceptions import RequestValidationErrorfrom fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)async def validation_handler(request: Request, exc: RequestValidationError): safe = [ {"type": e["type"], "loc": e["loc"], "msg": e["msg"]} # no "input" for e in exc.errors() ] return JSONResponse( {"detail": safe}, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, )Dropping input keeps everything a client legitimately needs — which field, and why —
and removes the echo. Clients that render field-level messages keep working, because
loc and msg are what they read.
curl -s -X POST https://api.example.com/signup \ -H 'Content-Type: application/json' \ -d '{"email":"probe@example.test","password":"CANARY-VALUE-12345"}' \ | grep -c 'CANARY-VALUE-12345'Deliberately omit a required field so the error is missing rather than a type error —
that is the case that echoes. 0 means the handler is stripping the input. Anything
higher means the submitted body is coming back.
Because this reaches your logs as much as the client, check there too:
grep -rl 'CANARY-VALUE-12345' /var/log/ 2>/dev/nullAnything that matches is a system now holding a test credential, and would have been holding a real one.
Use SecretStr for the field itself
Section titled “Use SecretStr for the field itself”Pydantic’s SecretStr renders as ********** wherever the model is displayed, which
covers the paths a response handler does not — a model that ends up in a log line, an
exception message, or a debugger frame.
from pydantic import BaseModel, SecretStr
class Signup(BaseModel): email: str password: SecretStrReading it takes an explicit .get_secret_value(), which is the point: the plaintext
appears only where somebody asked for it. This is worth doing alongside the handler
rather than instead of it — SecretStr protects the value once it is inside a model,
and the missing case echoes the raw submitted body from before parsing, which no
field type can reach.
Removing input breaks any client that reads it — most obviously a front end that
re-renders the rejected value into the form to preserve what the user typed.
That is a real feature to lose. If yours does it, the fix is for the client to keep its own form state rather than recovering it from the error response, which it should be doing anyway.
Check before shipping:
grep -rnoE 'detail\[[0-9]*\]\.input|\.input\b' frontend/src/ | sort -uAutomated API clients almost never touch input, so the risk is concentrated in
browser front ends you control.
Related
Section titled “Related”- Debug mode and tracebacks — the larger disclosure on the error path
- response_model — controlling the success path the same way