ASVS 5.0 V16.5.1 requires that when an unexpected or security sensitive error occurs, the consumer gets a generic message. No stack traces, SQL, secret keys or tokens may appear in the response. It is a Level 2 requirement.
The detail goes to the log, tagged with an id. The client gets a short message and that same id, so support can find the record.
Why it matters
Error output is free reconnaissance. A stack trace names frameworks and versions. A SQL error shows table and column names. A connection string error can contain a host and a password. Attackers deliberately send malformed input to trigger these. See CWE-209.
Security sensitive errors need extra care. "User not found" versus "wrong password" tells an attacker which accounts exist.
How to implement it
Install one central error handler per application and make sure debug modes are off in production.
Express:
const crypto = require("crypto");
app.use((err, req, res, next) => {
const id = crypto.randomUUID();
logger.error({ errorId: id, path: req.path, err: err.stack });
if (res.headersSent) return next(err);
res.status(500).json({ error: "Something went wrong.", errorId: id });
});
Spring Boot:
@RestControllerAdvice
class ApiErrors {
private static final Logger log = LoggerFactory.getLogger(ApiErrors.class);
@ExceptionHandler(Exception.class)
ResponseEntity<Map<String, String>> unexpected(Exception e) {
String id = java.util.UUID.randomUUID().toString();
log.error("unexpected error id={}", id, e);
return ResponseEntity.status(500).body(Map.of("error", "Something went wrong.", "errorId", id));
}
}
Flask:
@app.errorhandler(Exception)
def handle(e):
if isinstance(e, HTTPException):
return jsonify(error=e.name), e.code
eid = uuid.uuid4().hex
app.logger.exception("unexpected error id=%s", eid)
return jsonify(error="Something went wrong.", errorId=eid), 500
Also set server.error.include-stacktrace=never in Spring, turn off DEBUG in Django and Flask, and replace default web server error pages. For login and password reset, use one message for all failure causes.
How to test it
Trigger errors on purpose and read the raw response, headers included:
- Send invalid JSON, an oversized body, a wrong content type, and a path that does not exist.
- Inject a single quote into a parameter that reaches the database.
- Stop the database and call an endpoint.
- Request
/with an unsupported HTTP method.
curl -si -X POST https://app.example/api/orders -H 'Content-Type: application/json' -d '{bad'
Fail the test if the response contains Traceback, at com., SELECT, Exception in, a file path, or a version banner. A DAST scan will run these checks across the whole app.
Click through it at your own pace, or open it full screen.
Handling the awkward cases
Validation errors are a special case. It is fine, and often helpful, to tell a user which field was wrong, as long as the message is yours and does not echo internal names or the raw input back unescaped. Keep a clear line between errors the user can fix (bad email format) and errors only the operator can fix (database down). The first gets a specific, safe message. The second gets the generic one.
Security sensitive errors need the same message regardless of cause. Login, password reset, account lookup and token refresh should return identical text and similar timing for known and unknown accounts. If response time differs, an attacker can still tell the cases apart.
Do not forget the layers around the app
The framework handler only covers errors the framework sees. Reverse proxies, load balancers, API gateways, CDNs and web servers each have default error pages that may show product names and versions. Configure custom pages at each layer. Also check non HTTP surfaces: GraphQL error arrays, gRPC status details, WebSocket close reasons, and mobile API error bodies can all carry the same leaks.
Correlation, not exposure
The error id pattern is the bridge between safety and supportability. Generate the id, put it in the log record with the full detail, and put only the id in the response. Include it in a response header as well so client teams can log it. Make sure log access is restricted, because the logs now hold what you refused to show users.
Keep it from regressing
Add a test to the suite that sends five or six malformed requests to every service and fails on any response body that matches patterns for stack frames, SQL keywords and file paths. Run it on every build. A dependency upgrade can switch a framework default back to verbose errors, and the test catches that.
Common mistakes
- Debug mode left on in a staging system that is reachable from the internet.
- A custom handler for the API but default pages for the proxy or load balancer.
- Returning
e.getMessage()to the client "just for the message". - Different error text for unknown user and wrong password.
- Logging nothing, which makes the generic message useless to support.
A short checklist for reviews
When you review a service, walk through these questions. Is debug mode off in every production profile? Is there exactly one place that turns exceptions into responses? Do all response bodies for failures come from a fixed set of messages? Are headers such as Server and X-Powered-By trimmed? Are validation messages written by you, not copied from a library? Do logs hold the detail with an id that matches the response?
Write the answers into the pull request template for services, so each new service starts from the safe pattern rather than discovering the leak later in a penetration test. Teams that do this find the review gets faster over time, since the answers are the same for most services.
Quick check
What should the client see after a database failure?
A generic message and an error id. The driver message, query and connection details stay in the log.
Why return an id at all?
It lets support match a user report to the exact log entry without exposing any internals.