Input and resource controls
Return safe errors
Use consistent status codes and problem details without exposing stack traces, queries, secrets, internal hosts, or authorization-sensitive existence.
Errors are part of your API contract. A good error tells the client what went wrong and what to fix. A bad error also tells an attacker how your system is built.
Every stack trace, SQL fragment, or internal hostname in a response is free reconnaissance. Let’s stop handing it out.
Use a stable, safe shape
Return a stable type, a title, the status, a safe detail, and a request ID. The problem details format from RFC 9457 gives you a conventional structure, so you don’t have to invent one:
{
"type": "https://api.example.com/errors/validation",
"title": "Invalid request body",
"status": 400,
"detail": "items must contain between 1 and 100 entries",
"requestId": "req_7f3ab910"
}
Everything here is safe to show anyone. The detail says what the client did wrong, not how the server works.
The private diagnostics go to protected logs. The request ID is the link between the two:
# internal log line, never in the response
req_7f3ab910 POST /invoices 400 ValidationError items.length=50000 user=usr_8817
A client reports req_7f3ab910, you search your logs, and you have the full story. Nothing sensitive ever left the building.
Keep error types stable so clients can rely on them, but don’t expose internal exception classes. A type of validation survives a refactor. PostgresUniqueViolationError does not, and it names your database for free.
Don’t confirm what the caller may not know
An error like invoice 193 belongs to another account looks helpful. It’s not. It confirms invoice 193 exists. Repeat that for a range of IDs and you have an enumeration oracle: a way to map every record without reading any.
Decide on purpose when a forbidden resource should look like a missing one:
curl -i https://api.example.com/invoices/inv_someone_elses \
-H "Authorization: Bearer $TOKEN"
# HTTP/1.1 404 Not Found <- same response as a truly missing invoice
The public response hides the difference. The protected log must still record the real cause. Your security team needs to tell a typo from a probing campaign, and they can only do that if the log says “forbidden” while the response said “not found”.
Verify every error path
Trigger each class of failure on purpose and read what comes back. Raw, not through your client library.
Here’s the miss I see most often. Your handlers return clean problem details. But the framework’s default 500 handler still prints a stack trace when something throws outside your code. Send a request that breaks early, like an unparseable body or an oversized header, and look at the raw response. That’s where the traces hide.
Try this on your own API: capture the public response and the internal log line for a missing, forbidden, invalid, rate-limited, and failed request. Prove the public side has stable codes and request IDs, while stack traces and object existence stay private.
Lesson completed