Prefer returning a Result from handlers and mapping errors to a status code. As a safety net, add tower-http's CatchPanicLayer, which catches panics and converts them into 500 Internal Server Error responses. It is a backstop, not an error handling strategy, and its docs say using panics for error handling is not recommended.
What the layer does
The tower-http documentation says CatchPanic "catches panics and converts them into 500 Internal Server responses". The default response comes from DefaultResponseForPanic, and CatchPanicLayer::custom lets you build your own response. The docs also state plainly: "using panics for error handling is not recommended. Prefer instead to use Result whenever possible."
The page does not say how the layer behaves when a program is built with panic set to abort. Because it turns a panic into a response, it logically depends on unwinding, but treat that as something to verify in your own build before you rely on it.
Why a panic in a handler is a security matter
Most panics are ordinary bugs: an unwrap on bad input, an index out of range, an integer conversion. Whenever user input can reach one of these, an attacker can trigger it on demand. Depending on runtime and settings, that can drop one request, kill a worker task or take down the process.
Panics also tend to print messages and backtraces. Keep those in logs, not in responses.
Patterns that reduce panics
| Instead of | Prefer |
|---|---|
| unwrap or expect on user input | Return an error with the question mark operator |
| Direct indexing such as items[i] | get(i), then handle None |
| Unchecked arithmetic on sizes | checked_add and checked_mul, then handle None |
| Silent as casts for sizes | try_from, then handle the error |
| Unbounded recursion on nested input | Depth limits in parsers |
The post on integer overflow in debug and release builds explains why arithmetic needs explicit handling.
Example
use tower_http::catch_panic::CatchPanicLayer;
let app = Router::new()
.route("/api/items/:id", get(get_item))
.layer(CatchPanicLayer::new());
If you need a JSON body and a request identifier in the response, use the custom constructor and keep the panic message out of it.
What to log
A useful panic record has the route, a request identifier, the build version and the panic location, and nothing from the request body. Panic messages can contain the value that caused the problem, such as a slice of user input, so treat the log as sensitive and set a short retention period. If you forward logs to a third party service, scrub the message field.
Steps
- Search handlers for unwrap, expect, direct indexing and as casts on request data.
- Replace them with fallible code that returns a proper error type.
- Add CatchPanicLayer as the outermost layer.
- Log panics with route and request identifier, and alert on them.
- Confirm in a test that a forced panic returns 500 in your actual release build profile.
- Fuzz any parser that handles untrusted input.
Quick check
Does CatchPanicLayer catch panics in tasks I spawn?
The layer wraps the service call. A task you spawn separately is outside it, so handle errors and panics there yourself.
Should the 500 response include the panic message?
No. Return a generic message and log the details, as OWASP error handling guidance recommends.
Safeguard's dependency scanning and SBOM generation read Rust lock files, so the crates you ship are inventoried and checked against advisories, and the free scan is at gold.safeguard.sh/scan.
Sources
Last reviewed: 19 October 2026.