Skip to content

Validation error disclosure

Severity: lowApplies to: FastAPI 0.100+Applies to: Pydantic 2.xFacts last verified 2026-07-29 against FastAPI 0.141.0 · Pydantic 2.13.4

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.

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.

if you see this
{"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.4

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.

the fix
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from 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.

verify it workedRun this in: http response
Terminal window
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:

Terminal window
grep -rl 'CANARY-VALUE-12345' /var/log/ 2>/dev/null

Anything that matches is a system now holding a test credential, and would have been holding a real one.

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: SecretStr

Reading 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.

before you ship this

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:

Terminal window
grep -rnoE 'detail\[[0-9]*\]\.input|\.input\b' frontend/src/ | sort -u

Automated API clients almost never touch input, so the risk is concentrated in browser front ends you control.