ASVS 5.0 V2.3.3 requires that transactions are used at the business logic level so that an operation either succeeds in its entirety or rolls back to the previous correct state. It is a Level 2 requirement.
Why it matters
Half finished operations create states nobody planned for: money debited but never credited, stock reserved without an order, an account created without its permissions. Attackers can trigger failures on purpose, by sending a request that fails halfway, and then profit from the inconsistent state.
How to implement it
Wrap every multi step change that must stay consistent in a database transaction and let any error roll it back.
with db.begin(): # SQLAlchemy: commits on success, rolls back on exception
src = accounts.get_for_update(src_id)
dst = accounts.get_for_update(dst_id)
if src.balance < amount:
raise InsufficientFunds()
src.balance -= amount
dst.balance += amount
ledger.add(src_id, dst_id, amount)
Lock the rows you read for update, so another request cannot change them in between. Keep transactions short and avoid network calls inside them. When a step touches another system, such as a payment provider, use an idempotency key and a compensating action, since a database rollback cannot undo an external call.
How to test it
Inject a failure after each step: throw an exception, kill the process, drop the connection. After each run, check that either all changes are visible or none are. Add a test that sends the same request twice and confirms it applies once.
Isolation levels and retries
A transaction protects against partial failure, but concurrent transactions can still interfere. Know the isolation level your database uses by default. For operations that read a balance and then update it, take a row lock or use an atomic update. Under stricter isolation levels the database may abort one of two conflicting transactions, so write the code to retry a small number of times on a serialization failure.
Outbox pattern
When a database change must trigger a message or a call to another service, write the message to an outbox table inside the same transaction, and have a separate worker deliver it. That way the change and the message either both exist or neither does.
Common mistakes
- Committing after each statement because autocommit is on.
- Catching the exception inside the transaction and carrying on.
- Doing the external call first and the database write after.
Quick check
Can a database transaction roll back an email already sent?
No. Do external side effects after the commit, or use a queue written inside the same transaction.