Same payment or action submitted concurrently results in duplicate processing before idempotency keys or database locks take effect.
TL;DR
balance >= amount check before any UPDATE commitsA duplicate transaction race condition occurs when the same financial operation — a withdrawal, transfer, payment, payout, or account credit — is executed more than once because the system has no idempotency mechanism or because the existing mechanism is not enforced atomically. Unlike a coupon reuse race (which bypasses a usage counter), a duplicate transaction race exploits the absence of any "already processed" check: the endpoint simply processes every valid request it receives, regardless of duplicates.
The root vulnerability is a missing or non-atomic deduplication step. Systems that do implement idempotency keys often have subtle races in the key-check logic: they query whether the key exists (SELECT), then insert it (INSERT), then process the request — with a race window between the SELECT and INSERT where two concurrent requests both see "key not found" and both proceed to process.
This pattern is particularly prevalent in fintech, payment processors, crypto exchanges, and multi-tenant SaaS billing. The financial impact can be severe: a successful balance drain leaves accounts negative, may trigger fraudulent payouts, or generates duplicate account credits that attackers cash out. Doyensec's "Database Race Conditions in AppSec" (2024) identified this pattern in multiple production fintech platforms running Hibernate with default READ COMMITTED isolation.
The classical balance-drain attack exploits the two-step check-and-debit:
1. CHECK: SELECT balance FROM accounts WHERE id=X → balance=100
2. [RACE WINDOW — N concurrent requests all read balance=100]
3. DEBIT: UPDATE accounts SET balance = balance - 80 WHERE id=X AND balance >= 80All N concurrent requests pass step 2 (balance=100 ≥ 80 = true). All N commit their UPDATE in step 3. The database processes N debits of 80 against a starting balance of 100 — resulting in 100 - 80*N. With N=5, the balance is -300.
# Balance-drain PoC — CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:N
import asyncio
import httpx
async def balance_drain(
client: httpx.AsyncClient,
balance_url: str,
withdraw_url: str,
auth_headers: dict,
amount: int,
n: int = 8
):
"""
Classical TOCTOU balance drain:
balance = SELECT balance — check
if balance >= amount: UPDATE balance = balance - amount — use
N parallel requests all pass check before any UPDATE commits.
"""
# READ pre-race balance
pre = (await client.get(balance_url, headers=auth_headers)).json()["balance"]
# RACE: N concurrent withdrawals via HTTP/2 single-packet
tasks = [
client.post(withdraw_url, headers=auth_headers,
json={"amount": amount, "destination": "attacker_account"})
for _ in range(n)
]
responses = await asyncio.gather(*tasks, return_exceptions=True)
# READ post-race balance
post = (await client.get(balance_url, headers=auth_headers)).json()["balance"]
successes = sum(1 for r in responses if not isinstance(r, Exception) and r.status_code == 200)
return {
"pre_balance": pre,
"post_balance": post,
"withdrawal_successes": successes,
"amount_drained": pre - post, # Should be `amount` if protected, `amount*N` if vulnerable
"race_confirmed": successes > 1 and (pre - post) > amount,
}The OAuth code double-redemption follows the same non-atomic check-and-mark pattern:
# OAuth authorization code double-redemption
async def oauth_double_redeem(token_endpoint: str, code: str, client_creds: dict):
"""Exchange the same authorization code twice concurrently."""
body = {
"grant_type": "authorization_code",
"code": code,
**client_creds
}
async with httpx.AsyncClient(http2=True) as client:
r1, r2 = await asyncio.gather(
client.post(token_endpoint, data=body),
client.post(token_endpoint, data=body),
)
if r1.status_code == 200 and r2.status_code == 200:
t1 = r1.json()["access_token"]
t2 = r2.json()["access_token"]
if t1 != t2:
return True # Two distinct token pairs from one code
return False| Variant | Mechanism | Impact | Typical Bounty |
|---|---|---|---|
| Balance drain | N concurrent withdrawals past balance check | Negative balance, fund theft | $5K–$50K |
| Double payout | Race between payout trigger and completion flag | Duplicate disbursement | $2K–$20K |
| OAuth code double-redemption | Concurrent code exchange before invalidation | Two token families from one code | $2K–$20K |
| Refresh token race | Two refresh requests before rotation completes | Persistent access after revocation | $500–$5K |
| Account credit duplication | Race on credit/reward claim endpoint | Duplicate account credits | $500–$3K |
| Subscription race | Race on upgrade/downgrade state transition | Free tier after paid features credited | $500–$2K |
OAuth code double-redemption (HackerOne #55140) is the classic documented example. An authorization code is exchanged twice in parallel before the server atomically marks it consumed. Both requests receive valid access and refresh tokens. The attacker retains one token family after the victim logs out and revokes the other — yielding persistent unauthorized access.
Refresh token rotation race: modern auth flows (Auth0, Better Auth) rotate refresh tokens on each use. Two concurrent refresh requests both see the old token as valid before the rotation commits. Both receive new tokens — one from the rotation, one from a stale read. The attacker's token persists after the victim's revocation.
Multi-tenant fintech balance drain is the highest-value variant. Doyensec's 2024 research found platforms where a Spring Boot + Hibernate service running under default READ COMMITTED isolation allowed N concurrent withdrawals against the same account, each passing the balance >= amount guard, all debiting the full amount.
HackerOne #55140 — OAuth 2 Race (Internet Bug Bounty, $2,500)
Concurrent authorization code exchange requests to an OAuth 2.0 provider yielded two distinct access token and refresh token pairs from a single authorization code. The provider's code invalidation was a two-step SELECT-then-DELETE with a race window. The attacker retained a valid, unrevocable token pair after the victim's logout revoked the other. Reported to the Internet Bug Bounty program and awarded $2,500.
HackerOne #418767 — HackerOne 2FA Bypass via Race (125 upvotes)
A race condition in HackerOne's own two-factor authentication flow allowed bypassing the 2FA enforcement. The vulnerability exploited the window between the session user_id write and the mfa_pending flag write — a partial construction race that yielded an authenticated session without completing 2FA. The high upvote count (125) reflects its pedagogical value as a clean race example.
Cosmos/starport Faucet Race ($5,000)
A crypto faucet with "1 claim per address per 24h" enforced via a non-atomic INSERT-after-check allowed 30 concurrent claim requests, all passing the uniqueness check before any INSERT committed. Result: 30× the daily faucet allowance from a single address. Awarded $5,000 HackerOne bounty.
Tools for Humanity (Worldcoin) — Verification Check Race ($3,000)
A biometric verification check race condition allowed multiple accounts to be associated with a single biometric scan. The verification endpoint's uniqueness check was non-atomic with the account creation commit.
Doyensec "Database Race Conditions in AppSec" (2024)
Doyensec's published research surveyed multiple production fintech applications and found that Hibernate's default READ COMMITTED isolation level — combined with the common read-modify-write ORM pattern — consistently allowed balance-drain attacks. The research provided the first systematic enumeration of ORM-specific TOCTOU patterns across Rails, Django, Hibernate, and Mongoose.
/withdraw, /transfer, /payout, /purchase, /redeem-reward.balance_drained > expected_single_debit, the race is confirmed.access_token values, double-redemption is confirmed.# Duplicate transaction detection — read-race-read pattern
async def duplicate_tx_probe(client: httpx.AsyncClient,
balance_url: str,
action_url: str,
action_body: dict,
auth_headers: dict,
n: int = 10):
"""Confirm duplicate transaction race via state proof."""
pre = (await client.get(balance_url, headers=auth_headers)).json()
tasks = [client.post(action_url, headers=auth_headers, json=action_body)
for _ in range(n)]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = sum(1 for r in results if not isinstance(r, Exception) and r.status_code == 200)
post = (await client.get(balance_url, headers=auth_headers)).json()
# Confirm: if more than 1 success AND state changed by more than expected, race confirmed
return {
"successes": successes,
"state_delta": abs(pre.get("balance", 0) - post.get("balance", 0)),
"race_confirmed": successes > 1,
}BreachVex detects duplicate transaction races on authenticated endpoints through complementary techniques: identifying transactional URL patterns (/transfer, /withdraw, /payout), firing concurrent authenticated bursts, and confirming with a balance-comparison state proof. Financial endpoints always trigger dual-judge validation before marking Critical.
The Stripe pattern: client generates a UUID per logical operation; server deduplicates atomically:
# FastAPI + Redis idempotency — exactly-once execution
from fastapi import Header, HTTPException
import redis.asyncio as redis
import uuid
@router.post("/withdraw")
async def withdraw(
body: WithdrawalRequest,
idempotency_key: str = Header(...),
r: redis.Redis = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
idem_key = f"idem:{idempotency_key}"
# Atomic check-and-set — prevents concurrent duplicate processing
if not await r.set(idem_key, "processing", nx=True, ex=86400):
# Key exists — return cached result or wait for in-flight request
cached = await r.get(f"{idem_key}:result")
if cached:
return cached
raise HTTPException(409, "Duplicate request in progress")
try:
result = await execute_withdrawal(body, db)
await r.set(f"{idem_key}:result", result.model_dump_json(), ex=86400)
return result
except Exception:
await r.delete(idem_key) # Allow retry on failure
raiseFor critical financial operations, a database-level unique constraint on the operation ID provides a fallback even if the application-level idempotency check is bypassed:
-- Prevent duplicate transactions at the database level
CREATE TABLE transactions (
id BIGSERIAL PRIMARY KEY,
operation_id UUID UNIQUE NOT NULL, -- Client-supplied idempotency key
account_id BIGINT NOT NULL,
amount DECIMAL(15,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- INSERT will fail with unique violation on duplicate operation_id
INSERT INTO transactions (operation_id, account_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (operation_id) DO NOTHING
RETURNING id;
-- NULL returned = duplicate — return the original resultBEGIN;
-- Row-level lock prevents concurrent reads of the same balance
SELECT balance FROM accounts WHERE id = $1 FOR UPDATE;
-- Only one transaction holds the lock at a time — no duplicate withdrawal possible
UPDATE accounts
SET balance = balance - $2
WHERE id = $1 AND balance >= $2;
COMMIT;Redis Redlock provides distributed locks across multiple Redis instances, but Martin Kleppmann's 2016 critique identified a fundamental flaw: under network partition or clock skew, two clients can simultaneously hold the lock. For financial operations, prefer database-level SELECT FOR UPDATE over Redis Redlock. Use Redis SETNX only for non-financial flows where a missed lock does not result in financial loss.
A duplicate transaction race occurs when the same payment, transfer, or action is submitted multiple times concurrently and processed more than once before any deduplication mechanism takes effect. Unlike coupon races (which exploit a single-use limit), duplicate transaction races exploit the absence of idempotency: the system lacks a mechanism to detect and discard re-submitted operations. The result is duplicate charges, duplicate payouts, or duplicate account credits.
A fintech application checks 'balance >= amount' before executing a withdrawal. If the check (SELECT) and the debit (UPDATE balance = balance - amount) are separate non-atomic operations, N concurrent withdrawal requests all pass the check before any UPDATE commits. Each withdraws the full amount. Starting balance $100, withdrawal amount $80: all N requests pass the balance >= 80 check and all commit, leaving the account at $100 - $80*N = deeply negative. Doyensec documented this pattern in multiple fintech platforms in 2024.
In OAuth 2.0, an authorization code is single-use. When the client exchanges it for tokens, the server marks it consumed atomically. If the mark-as-consumed step is not atomic — for example, it does a SELECT to check validity then a DELETE in two steps — two concurrent exchange requests both see the code as valid and both receive distinct access/refresh token pairs. HackerOne #55140 (Internet Bug Bounty, $2,500) demonstrated this pattern on an OAuth provider in 2014 and it remains findable today.
A limit-overrun race (coupon, gift card) exploits a counter that should decrement but doesn't because the decrement is non-atomic. A duplicate transaction race exploits the absence of idempotency: there is no counter — the system simply has no mechanism to recognize that this exact operation was already processed. The fix for limit-overrun is atomic conditional UPDATE; the fix for duplicate transaction is idempotency keys (SETNX or DB unique constraint on operation ID).
Stripe requires a unique Idempotency-Key header on all write operations (charges, payouts, refunds). The key is client-generated (UUID v4). On first receipt, Stripe processes the request and caches the result keyed by the idempotency key for 24 hours. Subsequent requests with the same key return the cached response without re-processing. If two concurrent requests arrive with the same key before the first completes, one is serialized and returned the same result. This guarantees exactly-once semantics.
Doyensec's 'Database Race Conditions in AppSec' (2024) surveyed multiple production fintech platforms and found that Spring Boot + Hibernate applications running with default READ COMMITTED isolation consistently allowed balance-drain attacks. The root cause: Hibernate's read-modify-write ORM pattern (load entity → check balance → save) executes as two separate SQL round-trips under READ COMMITTED. Both round-trips are individually consistent, but a concurrent transaction can commit a withdrawal between them. Doyensec recommended either upgrading to SERIALIZABLE isolation, using SELECT FOR UPDATE within the transaction, or switching to a native atomic UPDATE WHERE balance >= amount pattern bypassing the ORM read-modify-write cycle entirely.
Modern OAuth 2.1 and OIDC flows use refresh token rotation: each use of a refresh token invalidates it and issues a new one. If two concurrent requests both present the same refresh token before rotation commits, both receive new distinct tokens. The second token is not tracked in the first rotation's invalidation chain. An attacker who races a token rotation retains a valid, long-lived refresh token after the victim revokes the 'main' one. This is especially dangerous because refresh tokens are long-lived (days to months) — persistent unauthorized access after the victim believes they've logged out. Auth0, Better Auth, and several OIDC providers patched this in 2023–2024 by implementing atomic token rotation with database-level unique constraints.
A valid duplicate transaction race report requires the read-race-read proof: (1) GET /account/balance before the burst — record the pre-race balance. (2) Execute N concurrent POST /withdraw requests with identical body and auth credentials via HTTP/2 single-packet. (3) GET /account/balance after the burst — record the post-race balance. (4) Calculate: if post_balance = pre_balance - amount × N_successes instead of pre_balance - amount × 1, the race is confirmed. Include the exact number of 200 OK responses, the withdrawal amount, pre/post balances, and the HTTP/2 request log showing sub-1ms delivery times. Reports without state proof are rejected by most fintech programs as unconfirmed.
Idempotency means the same operation can be submitted multiple times but produces the same result — subsequent submissions are recognized and the original result is returned. Atomicity means a check-and-act sequence executes as an indivisible unit — no concurrent operation can observe an intermediate state. Both are required: idempotency alone fails if the idempotency key check is itself non-atomic (SELECT key + INSERT key in two steps has a race). Atomicity alone fails for distributed systems where the same request arrives on two different nodes. The correct defense is both: atomic key acquisition (Redis SETNX or DB UNIQUE constraint with ON CONFLICT) AND application-level idempotency logic that returns the cached result for duplicate keys.
SELECT FOR UPDATE prevents balance-drain races within a single database transaction on a single database instance, but fails across microservices for two reasons: (1) microservices typically own separate databases — a lock acquired in service A cannot block a write in service B's database; (2) distributed transactions (XA transactions, two-phase commit) introduce their own race windows during the prepare-commit phase and add significant latency. The correct cross-service solution is an idempotency key stored in a shared Redis cluster (SETNX) or a saga pattern with compensating transactions. For same-database microservices sharing a PostgreSQL instance, SELECT FOR UPDATE on the shared accounts table remains effective.