I Used Pydantic’s SecretStr and Still Leaked Passwords in My Logs — The Logging Security Gap Nobody…
It was 2 AM when I got the Slack alert. A junior dev had accidentally triggered a bulk import script that logged every request body to our…
I Used Pydantic’s SecretStr and Still Leaked Passwords in My Logs — The Logging Security Gap Nobody Talks About
It was 2 AM when I got the Slack alert. A junior dev had accidentally triggered a bulk import script that logged every request body to our Datadog dashboard. I opened the log stream, filtered by “error,” and there it was — row after row of plain-text passwords, API keys, and credit card numbers sitting in our centralized logs like they belonged there.
I stared at the screen for a solid minute, wondering how this was possible. We had done everything “right.” HTTPS everywhere. OAuth2 with short-lived JWTs. Input validation with Pydantic. And most importantly, we used SecretStr for every sensitive field. We were supposed to be safe.
That’s when I learned the most expensive lesson of my FastAPI career: Pydantic’s SecretStr protects your console. It does not protect your logs.
The False Sense of Security
If you’ve built anything serious with FastAPI, you’ve probably written code like this:
from pydantic import BaseModel, SecretStr
class UserLogin(BaseModel):
email: str
password: SecretStr
@app.post("/login")
async def login(data: UserLogin):
# Authenticate...
return {"status": "ok"}
And when you print that model, it looks bulletproof:
user = UserLogin(email="alice@example.com", password="SuperSecret123!")
print(user)
# email='alice@example.com' password=SecretStr('**********')
Beautiful. Secure. Reassuring. You pat yourself on the back, commit the code, and move on.
But here’s what nobody tells you in the tutorials: SecretStr only masks the repr() output. The moment you convert that model to a dictionary, a JSON payload, or pass it into a logger, the mask disappears like it was never there.
The Leak I Found in Production
Let me show you exactly how I discovered this. We had a simple logging middleware to trace requests for debugging:
@app.middleware("http")
async def log_requests(request: Request, call_next):
body = await request.body()
logger.info(f"Incoming request: {body.decode()}")
response = await call_next(request)
return response
Harmless, right? Just logging the raw request body. But here’s what actually landed in our log files:
{
"email": "alice@example.com",
"password": "SuperSecret123!",
"mfa_token": "847291"
}
Every login request. Every password reset. Every API key exchange. All sitting in plaintext in CloudWatch, searchable by anyone with log access.
And it gets worse.
The Three Hidden Leak Vectors
After that night, I audited every FastAPI project I’d ever touched. I found three places where SecretStr silently fails you:
1. model_dump() and dict()
This is the most common trap. Pydantic models are designed to serialize. That’s the whole point. But serialization doesn’t respect your secrets:
user = UserLogin(email="alice@example.com", password="SuperSecret123!")
# All of these expose the raw password:
user.model_dump() # {'email': '...', 'password': 'SuperSecret123!'}
user.model_dump_json() # '{"email":"...","password":"SuperSecret123!"}'
dict(user) # {'email': '...', 'password': 'SuperSecret123!'}
If your middleware, your error handler, or a third-party library calls any of these, your SecretStr is worthless.
2. Unhandled Validation Errors
FastAPI’s automatic validation is a double-edged sword. When a request fails validation, it generates a detailed error response — and if you’re not careful, that error gets logged with the full payload:
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
logger.error(f"Validation failed: {exc.body}") # LEAK!
return JSONResponse(
status_code=422,
content={"detail": exc.errors()}
)
Notice exc.body. That's the raw, unvalidated request body. If a user sent a malformed login request, their password was now in the error logs. Forever.
3. APM and Error Tracking Tools
This one almost made me quit software. We had Sentry configured to capture unhandled exceptions. What we didn’t realize was that Sentry’s FastAPI integration, by default, captures the request body for context. So every time a 500 error occurred during login, Sentry stored the full JSON payload — passwords and all — on their servers.
Datadog, New Relic, and most APM tools do the same if you enable request body tracing. You didn’t leak the password to your logs. You leaked it to a third party.
The Fix: A Defense-in-Depth Logging Strategy
I spent the next week rebuilding our logging pipeline from scratch. Here’s the system I now use in every FastAPI production deployment.
Step 1: Build a Recursive Scrubber
Don’t rely on SecretStr. Build a log filter that aggressively scrubs sensitive keys no matter how deep they're nested:
import copy
import re
SENSITIVE_KEYS = {
"password", "passwd", "pwd", "secret", "token",
"api_key", "apikey", "access_token", "refresh_token",
"authorization", "credit_card", "cvv", "ssn"
}
SENSITIVE_PATTERN = re.compile(
r"|".join(re.escape(k) for k in SENSITIVE_KEYS),
re.IGNORECASE
)
def scrub_sensitive_data(obj):
if isinstance(obj, dict):
return {
k: "[REDACTED]" if SENSITIVE_PATTERN.search(k) else scrub_sensitive_data(v)
for k, v in obj.items()
}
elif isinstance(obj, list):
return [scrub_sensitive_data(item) for item in obj]
elif isinstance(obj, str):
return obj
else:
return obj
# Usage
raw_data = {"email": "alice@example.com", "password": "SuperSecret123!"}
safe_data = scrub_sensitive_data(raw_data)
# {'email': 'alice@example.com', 'password': '[REDACTED]'}
This function walks through any JSON-serializable structure and replaces sensitive values with [REDACTED]. No matter how nested your payload is, it finds the leaks.
Step 2: Create a Safe Logging Middleware
Replace your raw body logger with one that parses, scrubs, and then logs:
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
class SafeLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Read and restore body so downstream can still use it
body = await request.body()
try:
parsed = json.loads(body)
safe_body = scrub_sensitive_data(parsed)
logger.info("Incoming request", extra={"body": safe_body})
except json.JSONDecodeError:
logger.info("Incoming request", extra={"body": "[non-JSON payload]"})
# Reconstruct the request stream for the endpoint
async def receive():
return {"type": "http.request", "body": body}
request._receive = receive
response = await call_next(request)
return response
Critical detail: You must reconstruct the request body stream after reading it. FastAPI consumes the stream once. If you don’t put it back, your endpoint receives an empty body and every POST request breaks.
Step 3: Lock Down Your Error Handler
Never log exc.body directly. Scrub it first:
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def safe_validation_handler(request: Request, exc: RequestValidationError):
safe_body = scrub_sensitive_data(exc.body) if exc.body else None
logger.warning(
"Validation error",
extra={
"errors": exc.errors(),
"body": safe_body,
"path": request.url.path
}
)
return JSONResponse(
status_code=422,
content={"detail": "Invalid request format"}
)
Notice I also stripped the detailed Pydantic error messages from the public response. Detailed error messages are goldmines for attackers trying to reverse-engineer your data models.
Step 4: Configure Sentry (and Every APM Tool) Properly
For Sentry, disable request body capture:
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
sentry_sdk.init(
dsn="your-dsn",
integrations=[FastApiIntegration()],
send_default_pii=False, # NEVER send personally identifiable info
before_send=lambda event, hint: event, # Optional: add custom scrubbing here too
request_bodies="never" # Explicitly disable request body capture
)
For Datadog, disable DD_TRACE_HTTP_CLIENT_TAG_QUERY_STRING and never enable body tracing in production. For New Relic, set capture_params to false.
Rule of thumb: If a tool can see the request body, assume it will log it. Opt out by default.
The Production Checklist
Before your next deploy, audit your app against this:
- [ ] No
model_dump()ordict()on Pydantic models containing secrets without scrubbing - [ ] Logging middleware scrubs all request/response bodies before writing to disk
- [ ] Validation error handlers never log raw
exc.body - [ ] Sentry/APM tools have
send_default_pii=Falseand request body capture disabled - [ ] Log aggregation service access is restricted (not every engineer needs to read raw logs)
- [ ] Log retention is configured with automatic deletion (GDPR/CCPA compliance)
- [ ]
SecretStris still used — but treated as a convenience, not a security guarantee
What I Wish I Knew Sooner
SecretStr is not a security feature. It's a developer experience feature. It keeps passwords off your terminal during local debugging. It does not keep them out of your log aggregation dashboard, your error tracking service, or your database query logs.
Real security in FastAPI happens in the boring places: middleware, exception handlers, and third-party tool configurations. The places nobody writes tutorials about because they don’t feel like “coding.”
That 2 AM incident cost us a full security audit, a Datadog log purge, and a very uncomfortable conversation with our compliance team. The fix — a 40-line scrubber and three config changes — took two hours to implement.
The gap between vulnerable and secure isn’t skill. It’s awareness.
메타데이터
- post_id
- 6f63c2c77bfe
- slug
- i-used-pydantics-secretstr-and-still-leaked-passwords-in-my-logs-the-logging-security-gap-nobody-6f63c2c77bfe
- url
- https://medium.com/@rameshkannanyt0078/i-used-pydantics-secretstr-and-still-leaked-passwords-in-my-logs-the-logging-security-gap-nobody-6f63c2c77bfe
- canonical_url
- https://medium.com/@rameshkannanyt0078/i-used-pydantics-secretstr-and-still-leaked-passwords-in-my-logs-the-logging-security-gap-nobody-6f63c2c77bfe
- author_url
- https://medium.com/@rameshkannanyt0078
- status
- ok
- fetched_at
- 2026-08-17 02:46:44