notes 2026-09-04 · 6 min read

Race Conditions in Web Applications: Finding the Window

Race conditions in web apps: the window between check and update, the single packet attack over HTTP/2, multi endpoint races and what actually fixes them.

Race Conditions in Web Applications: Finding the Window

Two correct requests can produce an impossible result when they arrive inside the same time window.

That is the core of web race conditions.

The backend reads a state.

It makes a decision.

Then it writes another state.

If two executions cross that interval together, both may decide based on a reality that stops being true milliseconds later.

The examples below assume a lab or an authorized penetration test.

The race condition window

The classic example is a usage limit

Single-use coupon.

Balance.

Gift card.

Inventory quantity.

Invitation code.

OTP.

The logic may look correct:

1
2
3
if coupon.used == false:
    apply_discount()
    coupon.used = true

Now two requests arrive together.

Request A reads used=false.

Request B reads used=false.

Both apply the discount.

Then both write true.

The final state looks normal.

The effect happened twice.

The bug lives between check and update

This pattern has several related names.

Check-then-act.

TOCTOU.

Limit overrun.

The idea is similar.

A window exists between checking a condition and making the state change final.

The smaller the window, the more precise the attack needs to be.

Sending two requests by clicking quickly often fails.

Network jitter hides the collision.

HTTP/2 improved testing significantly

PortSwigger popularized the single-packet attack for web race conditions.

With HTTP/2, several requests can be prepared and their final frames completed inside one TCP packet.

This reduces network-jitter interference.

Burp Repeater can group requests and send them in parallel.

During discovery, twenty or thirty requests may increase the chance of a collision.

Afterward, I reduce the number until the smallest reproducible sequence is clear.

Requests entering the same window

Benchmark before racing

First I send the operation sequentially.

One request at a time.

I want to know normal behavior.

Responses.

Timing.

Final state.

Side effects.

Then I send the same group in parallel.

If parallel execution creates something sequential execution never creates, there is a clue.

That baseline helps separate rate limiting, caching, or normal instability from an actual race.

The effect may appear after the response

A response does not always reveal the collision.

Every request may return 200.

The effect appears in the balance.

History.

Email.

Number of created objects.

Credit count.

Check second-order effects.

Race testing needs before-and-after state.

Multi-endpoint races are more interesting

Sometimes the collision needs different endpoints.

Example:

1
2
POST /checkout
POST /apply-coupon

Or:

1
2
POST /change-email
POST /request-password-reset

One endpoint changes state while another reads it.

That window may produce an unintended result.

The test becomes harder because the internal timing of each endpoint differs.

Connection warming and controlled repetition can help align the window.

Hidden multi-step sequences increase the surface

A simple endpoint may internally perform:

1
2
3
4
5
1. validate limit
2. call external service
3. write transaction
4. update balance
5. emit event

The pentester sees one request.

The backend performs five operations.

Another request may enter between them.

That is why race conditions appear even when the API exposes one route.

Session locking can hide the bug

Some frameworks serialize concurrent access to the same session.

You send twenty requests.

The backend handles them one at a time.

It looks safe.

Then you remove the session cookie or use two sessions accessing the same object.

The race appears.

Session locking only serialized that context.

The shared resource was still vulnerable.

The database decides a lot

A transaction alone does not guarantee race safety.

Isolation level matters.

Constraints matter.

Atomic updates matter.

Compare:

1
SELECT balance FROM wallet WHERE id = 42;

followed by:

1
UPDATE wallet SET balance = balance - 80 WHERE id = 42;

with an atomic operation:

1
2
3
4
UPDATE wallet
SET balance = balance - 80
WHERE id = 42
  AND balance >= 80;

In the second version, affected-row count can tell the application whether the operation actually succeeded.

That design removes a common race pattern.

A unique constraint beats check before insert

Registration wants unique email addresses.

The code does:

1
2
3
SELECT user WHERE email = X
if none:
    INSERT user

Two requests can pass the SELECT before either INSERT happens.

A database unique constraint turns the rule into an invariant at the layer controlling the state.

The application still needs to handle the resulting error correctly.

Password reset can create strange collisions

Reset flows combine user identity, token, session, and email.

Two parallel requests for different users may collide in shared state when implementation reuses session or variables incorrectly.

The result may associate a token from one operation with the user from another.

Modern single-endpoint race research matters because collisions are not limited to counters.

They can mix identities.

A method that works

Look for invariants.

Only once.

Balance never goes negative.

One email belongs to one account.

One token belongs to one user.

Benchmark sequential behavior.

Send parallel requests.

Look for deviation.

Reduce request count.

Repeat until the minimal condition is proven.

Race condition testing method

Remediation

Use atomic operations.

Put invariants in the database.

Use locking where necessary.

Choose an isolation level that matches the rule.

Avoid separate check and update when the database can perform both atomically.

Use idempotency keys for operations that need safe retries.

Test concurrency during development.

A rule that works one request at a time may still be broken in production.

Asynchronous queues have race conditions too

Concurrency does not need to happen inside one web process.

Two requests may publish messages into a queue.

Different workers consume those messages at the same time.

If both read the same state before writing, the race crosses the entire architecture.

This appears in payment processing, voucher issuance, account creation and balance reconciliation.

Testing needs to observe the final effect, not only HTTP response timing.

Optimistic locking turns state into a version

One common defense adds a version to the record.

The application reads version=8.

It later writes only if the version is still 8.

If another request updated the object first, the second operation fails and needs to retry against the new state.

This can work well when an exclusive lock would be too expensive.

It only works when every route modifying that state respects the version.

Idempotency and race protection solve different problems

An idempotency key prevents the same logical command from being processed twice during events such as payment retries.

It does not fix every race.

Two different operations may still compete for the same balance.

During review I identify the invariant first and choose concurrency controls from that rule.

References

PortSwigger Web Security Academy, Race conditions.

PortSwigger Research, Smashing the state machine.

This post is licensed under CC BY 4.0 by the author.