Error Handling as a Security Feature
After our detour into the past with our vintage computer deep dives, here we are looking into common security concerns.
Most developers think of error handling as a reliability concern, catch exceptions, log them, show the user a friendly message. But the more I've studied real-world vulnerabilities, the more I've come to see error handling as one of the most critical security boundaries in any application. Verbose error messages leak implementation details to attackers. Uncaught exceptions bypass security checks. Inconsistent error responses enable user enumeration. And swallowed errors hide security-relevant failures that should be setting off alarms. In this post, I want to cover the security implications of error handling patterns that keep showing up across languages, and how to build error handling that actually strengthens your security posture instead of undermining it.
Information Disclosure Through Error Messages
The most common security mistake in error handling is returning internal details to the user. It's also one of the easiest to exploit.
Python, Stack Traces in Production
from flask import Flask
app = Flask(__name__)
app.config["DEBUG"] = True # VULNERABLE: debug mode in production
@app.route("/api/user/<int:user_id>")
def get_user(user_id):
user = db.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return jsonify(dict(user)) # Throws TypeError if user is None
With DEBUG = True, Flask returns the full stack trace including:
- File paths revealing the application's directory structure
- Database query strings showing table and column names
- Python version and installed package versions
- Local variable values at each stack frame
This kind of information disclosure is a goldmine for crafting targeted attacks, SQL injection becomes easier when you know the table and column names, and vulnerable package versions become obvious. It's like the application is handing you a blueprint.
Java, Exception Details in API Responses
@GetMapping("/api/user/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found: " + id));
return ResponseEntity.ok(user);
} catch (Exception e) {
// VULNERABLE: returns exception message and class name
return ResponseEntity.status(500).body(Map.of(
"error", e.getClass().getName(),
"message", e.getMessage(),
"trace", Arrays.toString(e.getStackTrace())
));
}
}
The response includes java.lang.RuntimeException, the full stack trace with class names and line numbers, and potentially sensitive data embedded in exception messages (like SQL queries in SQLException.getMessage()). Spring Boot apps with default error handling can expose the entire class hierarchy this way.
Secure Error Response Pattern
import logging
import uuid
logger = logging.getLogger(__name__)
@app.errorhandler(Exception)
def handle_error(error):
error_id = str(uuid.uuid4())
logger.error("Unhandled error %s: %s", error_id, error, exc_info=True)
# Return generic message with correlation ID
return jsonify({
"error": "An internal error occurred",
"error_id": error_id
}), 500
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleException(Exception e) {
String errorId = UUID.randomUUID().toString();
log.error("Unhandled error {}: {}", errorId, e.getMessage(), e);
return ResponseEntity.status(500).body(Map.of(
"error", "An internal error occurred",
"errorId", errorId
));
}
}
Log the full details server-side with a correlation ID, return only the correlation ID to the user. Support teams can look up the error by ID without exposing internals. Simple, effective, and widely recommended.
Errors That Bypass Security Checks
Go, Ignored Error Returns
Go's explicit error handling is a strength, but only when errors are actually checked. This is one of the more dangerous patterns in Go code:
func authenticateRequest(w http.ResponseWriter, r *http.Request) (*User, error) {
token := r.Header.Get("Authorization")
// VULNERABLE: error from validateToken is ignored
user, _ := validateToken(token)
return user, nil
}
func validateToken(token string) (*User, error) {
if token == "" {
return nil, fmt.Errorf("missing token")
}
claims, err := jwt.Parse(token, keyFunc)
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
return getUserFromClaims(claims), nil
}
By discarding the error from validateToken, the function returns a nil user without an error. The caller proceeds as if authentication succeeded. If downstream code doesn't check for a nil user, the request is processed without authentication. The _, _ pattern on a security-critical function is one of the scariest things to find in a Go codebase.
The Fix
func authenticateRequest(w http.ResponseWriter, r *http.Request) (*User, error) {
token := r.Header.Get("Authorization")
user, err := validateToken(token)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
if user == nil {
return nil, fmt.Errorf("authentication returned nil user")
}
return user, nil
}
JavaScript, Swallowed Promise Rejections
app.get('/api/admin/users', async (req, res) => {
// VULNERABLE: authorization check error is swallowed
try {
await checkAdminRole(req.user);
} catch (e) {
// Swallowed, execution continues even if authorization fails
}
const users = await db.query('SELECT * FROM users');
res.json(users);
});
If checkAdminRole throws (because the user is not an admin), the catch block swallows the error and execution continues to the database query. The authorization check is effectively disabled. This happens in real codebases where a developer adds a try-catch during debugging and forgets to put the error handling back. One empty catch block, and your entire auth layer is gone.
// SAFE: authorization failure stops execution
app.get('/api/admin/users', async (req, res) => {
try {
await checkAdminRole(req.user);
} catch (e) {
return res.status(403).json({ error: 'Forbidden' });
}
const users = await db.query('SELECT * FROM users');
res.json(users);
});
User Enumeration Through Error Differences
Inconsistent Error Messages
@app.route("/login", methods=["POST"])
def login():
username = request.form["username"]
password = request.form["password"]
user = db.query("SELECT * FROM users WHERE username = ?", (username,))
if user is None:
# VULNERABLE: different message reveals username existence
return jsonify({"error": "Username not found"}), 401
if not bcrypt.checkpw(password.encode(), user.password_hash):
return jsonify({"error": "Incorrect password"}), 401
return jsonify({"token": generate_token(user)})
An attacker can determine which usernames exist by checking whether the error says "Username not found" or "Incorrect password." This technique is well-documented for building username lists for credential stuffing attacks, it takes minutes and requires no special tools.
Consistent Error Responses
@app.route("/login", methods=["POST"])
def login():
username = request.form["username"]
password = request.form["password"]
user = db.query("SELECT * FROM users WHERE username = ?", (username,))
if user is None or not bcrypt.checkpw(password.encode(),
user.password_hash if user else DUMMY_HASH):
# Same message regardless of failure reason
return jsonify({"error": "Invalid credentials"}), 401
return jsonify({"token": generate_token(user)})
Error Handling in C: The Silent Failure
C has no exception mechanism, and that's where things get really dangerous. Errors are communicated through return values, and ignoring them is syntactically invisible:
// VULNERABLE: unchecked return values
void process_request(int client_fd) {
char buffer[1024];
int bytes_read = read(client_fd, buffer, sizeof(buffer));
// bytes_read could be -1 (error) or 0 (EOF)
// but we proceed as if it succeeded
char *decrypted = decrypt(buffer, bytes_read);
// decrypt could return NULL on failure
// but we proceed as if it succeeded
write(client_fd, decrypted, strlen(decrypted));
// strlen on NULL is undefined behavior, crash or worse
}
C codebases where none of the return values from security-critical functions are checked are more common than they should be. Here's what defensive C code looks like:
// DEFENSIVE: check every return value
void process_request(int client_fd) {
char buffer[1024];
int bytes_read = read(client_fd, buffer, sizeof(buffer));
if (bytes_read <= 0) {
log_error("read failed: %s", strerror(errno));
close(client_fd);
return;
}
char *decrypted = decrypt(buffer, bytes_read);
if (decrypted == NULL) {
log_error("decryption failed");
send_error_response(client_fd, 500);
close(client_fd);
return;
}
size_t len = strlen(decrypted);
ssize_t written = write(client_fd, decrypted, len);
if (written < 0) {
log_error("write failed: %s", strerror(errno));
}
free(decrypted);
close(client_fd);
}
Detection Strategies
Static Analysis
- Python: Bandit doesn't detect information disclosure in error handlers, which is a gap. Semgrep rules can match
DEBUG = Trueandexc_infoin response bodies. - Java: SpotBugs detects some information disclosure patterns. PMD flags empty catch blocks.
- Go:
errchecklinter detects ignored error returns, this should be mandatory for any Go project.go vetcatches some patterns too. - JavaScript: ESLint
no-emptyrule catches empty catch blocks. Custom rules are needed for swallowed async errors. - C: GCC
-Wunused-resultflags ignored return values from functions marked with__attribute__((warn_unused_result)). cppcheck detects some unchecked return values.
Manual Review
When reviewing error handling for security:
- Search for
catchblocks,exceptclauses, and error return checks. Verify that every error path either handles the error or propagates it. - Check all API error responses for information disclosure: stack traces, SQL errors, file paths, version numbers.
- Verify that authentication and authorization error paths return the same response format and timing as success paths.
- In Go code, search for
_, _and_ =patterns that discard errors. - In C code, verify that every function call that can fail has its return value checked.
Remediation
The principles that consistently matter:
- Log details internally, return generic messages externally. Use correlation IDs to link user-visible errors to detailed server logs.
- Never swallow errors in security-critical paths. Authentication, authorization, input validation, and cryptographic operations must fail closed, if the check errors, deny access.
- Use consistent error responses. Login failures, password resets, and account lookups should return identical error messages and response times regardless of whether the account exists.
- Disable debug mode in production. Flask's
DEBUG, Django'sDEBUG, Spring Boot'sserver.error.include-stacktrace, Express's error handler, all must be configured for production. - Treat error handling as a security control. Review error handling paths with the same rigor as authentication and authorization code. This is where some of the sneakiest vulnerabilities hide.