OWASP Top 10 for Vibe Coders, Part 13: Mishandling of Exceptional Conditions (A10:2025)

Something went wrong. Your app’s reaction to “something went wrong” is itself a security decision — and a surprising number of apps get it backwards.

Mindmap of this article: fail open vs fail closed, the fraud-check example, flooding the dependency, deciding the fallback on purpose, and logging the failure

Mishandling of Exceptional Conditions is A10:2025 in the OWASP Top 10; OWASP’s own page for it goes into more depth if you want to read further.

One of my more memorable conversations about this category went the other way round. A junior developer expected a user who couldn’t log in, because they’d forgotten their password, to be treated as a failure and logged as an error. I had the interesting job of explaining that it wasn’t exceptional at all: refusing a wrong password is the system doing exactly what it’s supposed to do, so as far as the code is concerned it’s a success. It is still worth recording, as the logging article argued, but as an ordinary event rather than an error. That conversation is a good way in to this category, because the hard part is deciding what counts as exceptional in the first place, and then deciding on purpose what the app does when something really is.

If you’ve ever wrapped a call in try/except just to make the error go away, this one is for you.

This is the newest category on the OWASP Top 10, and it’s a useful addition, because it names something that didn’t quite fit anywhere else: not a missing check, not bad input handling, but what your code decides to do once something has already failed. Every app has error handling — try/except blocks, timeouts, fallback behaviour for when a dependency doesn’t respond. This category is about the fact that error handling itself can be insecure, independent of everything else being correct.

Fail open, or fail closed?

There are two directions an app can go when something it depends on breaks: fail closed, which means “if I can’t be sure, assume the risky answer and block the action,” or fail open, which means “if I can’t be sure, let it through anyway.” For anything security-relevant, fail closed is almost always the right default — and a lot of real bugs in this category come from code that fails open without anyone deciding that on purpose.

Here’s a very realistic example for Sticker Shop. Say checkout calls an external fraud-check service before confirming an order — a sensible thing to do. This is the kind of code that tends to get written when nobody’s specifically thought about what “the fraud-check service doesn’t respond” should mean:

@app.route("/checkout", methods=["POST"])
def checkout():
    order = build_order_from_request(request)
    try:
        risk = fraud_check_service.assess(order)  # a call to a third party
        if risk == "high":
            return "Order flagged for review", 403
    except Exception:
        pass  # the fraud check is "nice to have" - don't block checkout over it

    confirm_order(order)
    return jsonify({"order_id": order.id, "status": "confirmed"})

Here’s what each part of that function is actually doing, because the danger is easy to walk straight past if you read it quickly:

If the fraud-check service times out, returns an error, or is simply unreachable for any reason, the except Exception: pass swallows that failure completely and execution falls straight through to confirm_order(order) — the same outcome as if the fraud check had explicitly said the order was safe. Nobody wrote a line that says “if the fraud check is broken, approve every order.” But that’s precisely what this code does, because the failure path and the success path end up in the same place.

Turning a flaky dependency into free orders

Once you notice that the failure path quietly behaves like approval, the attack writes itself: make the fraud-check call fail, on purpose, every time you want an order waved through. An attacker doesn’t need to touch Sticker Shop’s own code at all for this — they just need to make the dependency unavailable, by sending it far more requests than it can keep up with, at the moment they place their own order:

Sequence diagram: an attacker floods the fraud-check service with requests until it is too busy to answer anyone in time, then submits a risky, 500-item order with mismatched billing and shipping countries; the checkout route's call to the fraud check times out, the except block silently swallows the failure, and the server responds 200 OK confirmed without the fraud check ever actually running

Nothing about this attacks Sticker Shop’s own logic at all. It attacks the dependency, specifically because the dependency failing is what triggers the insecure fallback. This is exactly why this category is distinct from Insecure Design or Injection: there’s no flaw in the checkout logic’s happy path, and no malicious input anywhere in the order itself. The entire attack is “make the safety check unavailable,” because unavailable quietly means approved.

The fix: decide on purpose, and fail toward safety

The fix isn’t “never catch exceptions” — a payment page that shows an ugly crash page every time a third-party API has a slow day is a bad user experience, not a security improvement. The fix is to make the fail-open-or-fail-closed decision on purpose, in the open, instead of letting it happen by accident inside a blanket except:

@app.route("/checkout", methods=["POST"])
def checkout():
    order = build_order_from_request(request)
    try:
        risk = fraud_check_service.assess(order, timeout=2)
    except Exception as e:
        logging.warning(f"fraud check unavailable, order held for review: {e}")
        return "Order held for manual review - please try again shortly", 202

    if risk == "high":
        return "Order flagged for review", 403

    confirm_order(order)
    return jsonify({"order_id": order.id, "status": "confirmed"})

The difference is small in terms of lines changed, and large in terms of what it means: when the fraud check can’t be reached, the order is now held rather than silently approved, and the failure is logged (tying neatly back to the previous article — an unreachable fraud-check service happening over and over is the kind of pattern that deserves an alert). The app still degrades gracefully instead of crashing outright — it just degrades toward caution instead of toward risk.

The same question is worth asking anywhere your app has a try/except guarding something security-relevant: authentication checks, authorization checks, payment confirmation, access-control decisions. If an exception there is being swallowed, what does the code do next — and is that really the safer of the two options, or just the one that happened to be easiest to write?

Why “it’s just a dependency timing out” is the dangerous framing

The reason this category deserves its own place on the list, rather than being folded into Insecure Design, is the mindset it targets specifically: error handling tends to get written by someone thinking about reliability — “what if the network blips, what if the third-party service has a bad day” — not about security. Those feel like different problems, so the person writing a try/except around a flaky dependency is usually optimising for “don’t let a slow API ruin the user’s checkout experience,” which is a completely reasonable thing to care about. The security consequence sneaks in sideways, because the same code path that handles an innocent timeout gracefully also handles a deliberately induced timeout identically — the app has no way to tell the difference between “the fraud-check service is just having a slow Tuesday” and “someone is flooding the fraud-check service on purpose, specifically because they know what happens when it doesn’t answer.” Once you’ve seen that the two cases are indistinguishable to your code, it stops being a reliability question and becomes a security one, and the fail-open default stops being a reasonable shortcut.

Why this fix exists

The idea behind failing closed is older than the web. In 1975, Jerome Saltzer and Michael Schroeder’s paper on protecting information in computer systems listed “fail-safe defaults” among its design principles: base access on explicit permission, so that when something goes wrong, the default is to refuse. Railway signals work the same way, dropping to “stop” if the wire breaks. Software has spent decades relearning it one outage at a time, and the 2025 OWASP list finally gave it a category of its own, Mishandling of Exceptional Conditions, because so many incidents turned out to be an error that was caught, ignored and treated as success. Deciding on purpose what happens when a dependency fails is the fix, and the principle behind it is half a century old.

My take

This is the bilge pump that stops quietly. Nothing alarms, nothing crashes, and the boat keeps sailing nicely until the water reaches the floorboards. I’d search the codebase for bare except: and except Exception: pass today, because each one is a decision about failure that nobody made on purpose, and I’d decide for each external call whether it fails open or closed. What I wouldn’t do yet is bring in circuit breakers, retry libraries and chaos engineering. They’re excellent, purpose-built tools for systems with many services leaning on each other, and a sticker shop with one fraud check needs one well-written except block and an alert, both of which you now know how to write.

Check your own code

  1. “Search this codebase for every try/except block that wraps a security-relevant check - authentication, authorization, fraud or risk checks, payment confirmation. For each one, tell me what happens in the except branch: does it block the action, or does execution continue as if the check had passed?”
  2. “Find every external API or service call in this app. For each one, check what happens if that call times out or raises an exception - is there an explicit decision about whether to fail open or fail closed, or does a generic exception handler decide it by accident?”
  3. “Check whether any exception handler in this app silently does nothing (except Exception: pass, or similar) around code that affects money, access, or account state, with no logging and no safe fallback.”

Tested (Python 3.12.8 and Flask 3.1.3): with the fraud service healthy, a 500-item order was blocked with a 403 in both versions. With the service switched off, the except: pass version confirmed the order with a 200, while the fail-closed version held it with a 202. Code: gist.

Next in the series — the last one: wrapping up the OWASP Top 10, and a look ahead at the new risks AI agents themselves introduce.

This post is part of the OWASP Top 10 for Vibe Coders series; the first post introduces the series and what’s in it.