Give callers a stable, minimal error and keep the details in restricted logs. Map each failure to a status the client can act on, such as 400, 403, 409 or 503, in an RFC 9457 problem body with a correlation id, never a stack trace or SQL. Fail closed: an unavailable authorization check is a 503, not an allow. Roll back partial work in one transaction, and make retries safe with idempotency keys bound to the original inputs.
- An error contract covers the response, the state change, retry behavior and the evidence kept.
- A dependency failure during an authorization check must deny or return 503, never allow.
- Keep related writes in one transaction so a failure part-way through leaves no partial effect.
- A lost response after commit is not a failure; idempotency keys let the client retry safely.
- Turn off framework debug output in production, and test proxy and framework error pages as well as your own.
| Setting | Value | Note | Source |
|---|---|---|---|
| Public error body | RFC 9457 problem details, application/problem+json, with type, title, status and instance | Never the exception message, SQL, file paths or stack trace. | RFC 9457; OWASP ASVS v5.0.0 V16.5.1 |
| Status mapping | 400 invalid input, 401 unauthenticated, 403 denied, 409 conflict, 503 dependency unavailable | Return 404 for hidden resources when the disclosure policy requires it. | — |
| Authorization dependency failure | Deny or 503; never continue as allowed | — | OWASP ASVS v5.0.0 V16.5.3 |
| Django | DEBUG = False | — | Django deployment checklist |
| Spring Boot | spring.web.error.include-stacktrace=never and spring.web.error.include-message=never (the defaults) | Spring Boot 4 renamed these from server.error.*. spring.mvc.problemdetails.enabled=true turns on RFC 9457 bodies; it is off by default. | Spring Boot 4.1 application properties |
| ASP.NET Core | app.UseExceptionHandler() with builder.Services.AddProblemDetails() | The developer exception page belongs to the Development environment only. | ASP.NET Core: handle errors |
| Express | NODE_ENV=production and a final error-handling middleware | The built-in handler sends err.stack unless the environment is production. | Express: error handling |
- Make the policy service time out and confirm a 503 with no state change.
- Inject a failure after the first write and confirm every write in the operation rolled back.
- Drop the response after commit, retry with the same key and confirm one effect and the same result.
- Reuse an idempotency key with a different amount and confirm a 409 with no change.
- Trigger an unexpected exception and confirm the body carries a request id but no stack trace, SQL or secret, and that the restricted log has the entry.
- Run
python manage.py check --deployor your framework equivalent, then request a missing route and a failing upstream through the production proxy.
- Applies to
Failing closed without disclosing internals. Uniform messages do not prove uniform timing or successful recovery.
- Part of
- Workflow: Fix a finding
Learning goals and prerequisites
What you will learn
- Distinguish denied, unavailable and uncertain outcomes.
- Verify state rollback rather than only error text.
- Design retriable failures without duplicate business effects.
Before you start
Backend developers designing API error responses and failure paths, and reviewers of exception handling.
- Distinguish expected rejection, dependency failure and programming errors in a handler.
An error contract includes what the caller sees, what state changed, what may be retried and what evidence operators retain. Returning a generic 500 fixes only the presentation layer. A handler can hide its stack trace while still granting access because its authorization dependency timed out. Conversely, indiscriminately swallowing errors can make an operation appear successful even though no durable work happened. OWASP Top 10:2025 now gives this its own category, A10 Mishandling of Exceptional Conditions.
Consider a transfer of 30 credits from A to B. A starts with 100 and B with 10. The intended success is A=70 and B=40, with one transfer record. Authentication failure should produce 401 and no mutation. A known permission denial should produce 403 and no mutation. A malformed amount should produce 400. An unavailable permission service should produce the documented temporary failure, here 503, and must not be treated as an allow decision. An unexpected server failure should return a stable error code and opaque request id while keeping sensitive internals in restricted diagnostics.
A dangerous pattern catches any policy exception and substitutes true, or catches a failed debit and continues to credit the recipient. Group local debit, credit and ledger insertion in a transaction so a failure before commit rolls all three back. Check expected failures deliberately and let an appropriate boundary handler convert unexpected errors. Do not use a broad catch block to erase the difference between denied, unavailable and completed-but-unacknowledged.
A timeout after commit is different from a failure before commit. If the database committed the transfer but the response connection broke, the caller cannot know whether the transfer occurred. An idempotency key tied to caller, operation and material input lets a retry retrieve the original result. Without that context, advising 'try again' can duplicate the operation. External payments add another uncertainty boundary because a local database rollback cannot undo an accepted provider charge; retain an operation identity and reconcile before issuing a new one.
The Python program below implements the transfer contract with SQLite and returns RFC 9457 problem bodies. A policy outage becomes 503 before any write. Debit, credit, ledger row and the stored idempotent result share one transaction, so an exception between debit and credit rolls everything back and the caller sees only internal-error with its request id, while the log records the category without the exception text. A retry with the same key and inputs replays the stored result; the same key with a different amount returns 409. Run python3 transfer_errors.py. Eight threads racing with one key also produced exactly one transfer; /domains/appsec/business-logic/transaction-races/ covers that race in depth.
# Run: python3 transfer_errors.py
import json
import logging
import sqlite3
log = logging.getLogger("transfers")
class PolicyUnavailable(Exception):
pass
def problem(status, kind, request_id):
"""An RFC 9457 problem body (application/problem+json): stable fields, no internals."""
return status, {"type": f"https://errors.example.test/{kind}", "title": kind,
"status": status, "instance": f"urn:request:{request_id}"}
def transfer(db, policy, caller, key, amount, recipient, request_id, fault=None):
if type(amount) is not int or amount <= 0:
return problem(400, "invalid-amount", request_id)
try:
allowed = policy(caller, recipient, amount)
except PolicyUnavailable:
return problem(503, "policy-unavailable", request_id) # never treated as an allow
if not allowed:
return problem(403, "forbidden", request_id)
def replay(): # a retry: the same inputs replay the result, changed inputs conflict
stored = db.execute("SELECT amount, recipient, result FROM idempotency "
"WHERE caller = ? AND key = ?", (caller, key)).fetchone()
if stored and stored[:2] != (amount, recipient):
return problem(409, "idempotency-key-reused", request_id)
return stored and (200, json.loads(stored[2]))
if earlier := replay():
return earlier
try:
with db: # debit, credit, ledger row and stored result commit or roll back together
if db.execute("UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?",
(amount, caller, amount)).rowcount != 1:
return problem(409, "insufficient-funds", request_id)
if fault:
fault() # test hook: fail between the debit and the credit
db.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", (amount, recipient))
cur = db.execute("INSERT INTO transfers (src, dst, amount) VALUES (?, ?, ?)",
(caller, recipient, amount))
result = {"transfer": cur.lastrowid, "amount": amount, "recipient": recipient}
db.execute("INSERT INTO idempotency VALUES (?, ?, ?, ?, ?)",
(caller, key, amount, recipient, json.dumps(result)))
return 200, result
except sqlite3.IntegrityError: # a concurrent request with the same key committed first
return replay()
except sqlite3.Error as e:
# Restricted diagnostics: category and correlation id, not the exception text.
log.error("transfer failed request=%s category=%s", request_id, type(e).__name__)
return problem(500, "internal-error", request_id)
def fresh():
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE accounts (id TEXT PRIMARY KEY, balance INTEGER NOT NULL);
CREATE TABLE transfers (id INTEGER PRIMARY KEY, src TEXT, dst TEXT, amount INTEGER);
CREATE TABLE idempotency (caller TEXT, key TEXT, amount INTEGER, recipient TEXT,
result TEXT, PRIMARY KEY (caller, key));
INSERT INTO accounts VALUES ('A', 100), ('B', 10);
""")
return db
def state(db):
return (dict(db.execute("SELECT id, balance FROM accounts")),
db.execute("SELECT COUNT(*) FROM transfers").fetchone()[0])
def allow(*_):
return True
def unavailable(*_):
raise PolicyUnavailable()
def crash():
raise sqlite3.OperationalError("db password=TRAINING-SECRET table transfers")
logging.basicConfig(level=logging.CRITICAL)
db = fresh()
status, body = transfer(db, unavailable, "A", "K1", 30, "B", "req-1") # F1
assert status == 503 and state(db) == ({"A": 100, "B": 10}, 0)
status, body = transfer(db, allow, "A", "K1", 30, "B", "req-2", fault=crash) # F2
assert status == 500 and "TRAINING-SECRET" not in json.dumps(body)
assert state(db) == ({"A": 100, "B": 10}, 0) # the debit was rolled back
first = transfer(db, allow, "A", "K1", 30, "B", "req-3") # F3: committed, reply lost
assert first[0] == 200 and state(db) == ({"A": 70, "B": 40}, 1)
assert transfer(db, allow, "A", "K1", 30, "B", "req-4") == first # F4: replayed
assert transfer(db, allow, "A", "K1", 40, "B", "req-5")[0] == 409 # F5: key reused
assert state(db) == ({"A": 70, "B": 40}, 1)
assert transfer(db, allow, "A", "K2", True, "B", "req-6")[0] == 400 # bool is not an amount
assert transfer(db, lambda *_: False, "A", "K3", 30, "B", "req-7")[0] == 403
print("PASS: 503, rollback, replay and conflict cases keep A=70, B=40, one transfer")For each test, record balances and transfer rows before and after, not only the status. Fail after debit but before credit: the transaction must restore A=100 and B=10 with zero records. Fail after commit but before response: state must remain A=70 and B=40 with one record; a retry using the same key returns the existing result and does not transfer again. A log message alone cannot tell these two cases apart without the commit or operation identity.
Public error bodies should avoid SQL statements, file paths, tokens and exception strings that include submitted secrets. Detailed internal diagnostics still need access control, redaction and retention limits. Log structured error categories and request ids, as /domains/runtime/detection/security-event-logging/ describes, so a user-facing message can be correlated without including confidential content. Test proxy-generated and framework-generated errors as well as your own handlers; a debug page or upstream error body can bypass an otherwise careful application mapper. Preserve useful validation messages for ordinary users, while keeping account-enumeration and authorization information within the product's chosen disclosure policy.
Framework and proxy error pages bypass your own mapper, so check their production settings explicitly and request a failing route through the deployed path. The table lists the switch for common stacks. Debug consoles, Actuator endpoints, exposed .git folders and the other deployment-side leaks, with a probe to run after each release, are in /domains/appsec/secure-coding/information-disclosure-misconfiguration/.
| Stack | Production setting | What it prevents |
|---|---|---|
| Django | DEBUG = False; run manage.py check --deploy | Debug pages with settings, SQL and tracebacks |
| Spring Boot | spring.web.error.include-stacktrace=never and include-message=never, the defaults (server.error.* before Spring Boot 4) | Stack traces and exception messages in the default error body |
| ASP.NET Core | UseExceptionHandler() and AddProblemDetails(); developer exception page only in Development | Exception pages in responses |
| Express | NODE_ENV=production and a final error middleware | err.stack in the built-in error response |
| Reverse proxy | Custom 502, 503 and 504 pages | Upstream error bodies and server versions |
Apple's 2014 'goto fail' bug (CVE-2014-1266) is the cautionary tale: a duplicated goto fail statement skipped the signature check on the TLS server key exchange, so a man-in-the-middle could impersonate any TLS server to iOS and OS X clients. It is the archetype of failing open; error paths must fail closed and unreachable code must be caught in review and tests.
Suppose the transfer commits, then the audit-log transport fails. If the handler returns an ordinary failure and encourages a fresh retry, the caller may transfer twice unless idempotency preserves the original operation. Decide whether the audit event is part of the transaction's durable obligation. An outbox can store the audit intent with the transfer so a logging outage delays delivery without erasing the business result. For an action whose policy requires synchronous durable audit, fail before the action when that requirement cannot be met; do not invent the policy only after the transfer commits.
Error categories also affect retries. A 403 is a permission decision and should not trigger automatic retry with broader credentials. A 400 requires corrected input. A 503 may be retried under a bounded backoff using the same operation identity, ideally with a Retry-After header. A timeout after an unknown commit requires reconciliation or idempotent replay. Returning all of these as 200 with error text makes clients and monitoring less able to distinguish them; returning every condition as 500 can create avoidable retry storms.
Test cleanup paths as carefully as the original operation. If rollback or temporary-file removal also fails, preserve the primary failure and track residual state without exposing a raw nested exception to the caller. Never continue with an allow decision because constructing an error page threw another error. For streaming responses, headers or body bytes may already be sent before a later failure; the boundary must prevent sensitive release before authorization rather than relying on a final error mapper to take bytes back.
These cases explain why a global exception handler is necessary but insufficient. The operation needs an explicit state machine, and the error boundary needs a stable presentation and correlation contract.
Guided practice
Apply a complete failure contract to a two-account transfer.
Supplied scenario material
Initial: A=100, B=10, ledger empty. Amount=30, recipient=B, idempotency key=K1 for caller A.
Policy unavailable ->503; denied ->403; invalid input ->400. No mutation in those cases.
Debit, credit and ledger share one database transaction.
F1: policy throws timeout before transaction.
F2: permission allowed, database throws after debit before credit.
F3: commit succeeds, response socket closes.
F4: client retries F3 with K1, amount=30, recipient=B.
F5: client reuses K1 with amount=40.
Expected idempotency contract: same material inputs replay original result; changed inputs ->409 conflict.
Diagnostic exception sample: db password=TRAINING-SECRET, table transfers, request req-7.1. Classify precondition failure
Evaluate F1 and explain why defaulting permission to true is invalid.
Expected result and reasoning
Return 503, A=100, B=10, zero ledger rows. Unavailable evidence is neither an allow nor a denial of policy; granting the transfer would bypass the required authorization decision.
2. Apply transactional rollback
Trace F2 including the transient debit.
Expected result and reasoning
Inside the transaction A temporarily becomes 70. Failure rolls it back; durable state is A=100, B=10 and zero rows. Returning 500 without rollback would not repair the invariant.
3. Resolve uncertain acknowledgement
Trace F3 and F4 as one sequence.
Expected result and reasoning
After F3, A=70, B=40 and one ledger row. F4 returns that stored result with no additional debit or credit. The lost response did not undo the commit.
4. Prevent semantic key reuse
Evaluate F5 and draft public versus internal diagnostics for req-7.
Expected result and reasoning
F5 returns 409 with unchanged A=70,B=40. The public body may contain
error=internal_errorandrequestId=req-7, notTRAINING-SECRETor SQL details. Internal structured diagnostics record the failure category and operation identity with secret fields removed.
Paper exercise: balances, failures and the idempotency contract are supplied. transfer_errors.py above runs cases F1 to F5 against a real SQLite transaction.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP Error Handling
- MITRE CWE-209: sensitive information in error messages. CWE 4.20.
- RFC 9457: Problem Details for HTTP APIs. July 2023.
- OWASP Top 10:2025 A10 Mishandling of Exceptional Conditions
- Django deployment checklist. Django 6.1.
- ASP.NET Core: handle errors
- Express: error handling
- NVD: CVE-2014-1266, Apple TLS 'goto fail'
Reference information
- SDLC phase
- Development
- ASVS 5.0.0 requirements
- v5.0.0-16.5.1 · v5.0.0-16.5.3 · v5.0.0-13.4.2
- OWASP WSTG v4.2 tests
- WSTG-ERRH-01 · WSTG-ERRH-02