Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #11

OWASP A06: Insecure Design — Business Logic Flaws, Race Conditions and Rate Limiting

Mert Özen Nov 6, 2026 11 min 4 views
OWASP A06: Insecure Design — Business Logic Flaws, Race Conditions and Rate Limiting

An order endpoint that works correctly line by line refunds money for a negative quantity, accepts a single-use coupon twice and sells the last copy to two people. We separate design flaws from implementation bugs, find the missing requirements with STRIDE and abuse cases, and fix them with a conditional UPDATE, a transaction, .NET 10 validation and rate limiting.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

The Bookstore API order endpoint takes a book id, a quantity and an optional coupon code. There is no SQL injection in the code; every query is parameterized. There are no type errors, and the compiler doesn't print a single warning. Now three questions: what is the total if you send "quantity": -3? What happens if you try the single-use WELCOME10 coupon a second time? If one copy of a book is left in stock and two users order it at the same moment, how many orders get created?

All three answers are uncomfortable, and none of them is a "someone typed one line wrong" kind of answer. That is exactly what this post is about: a design that works correctly line by line but was not thought through in the first place.

Design Flaw or Implementation Bug?

A06:2025 Insecure Design moved down from fourth place in 2021 to sixth, but with 39 CWEs it is still one of the broadest categories on the list. Its CWEs show why it is called "design": storing passwords in plain text (CWE-256), improper privilege management (CWE-269), unrestricted file upload (CWE-434), trust boundary violation (CWE-501). None of them can be explained by a single bad line.

Here is how I find it useful to separate the two. In an implementation bug the design is right and the code gets it wrong: strings are concatenated where a parameter belongs, a check is forgotten. In a design flaw the code implements the design faithfully; what is missing is a requirement in the design itself. If the rule "a coupon can be used once" never made it into the data model, even the cleanest code will accept the coupon twice. A perfect implementation does not rescue an incomplete design.

That is why A06 is not caught by a code review checklist. It is caught by the question asked before the code is written: "who would abuse this feature, and how?" Every experiment in this post runs against the lab application on your own machine; only try these techniques on systems you own or have written permission to test.

The Vulnerable Order Endpoint

The lab has three tables: books (id, title, price, stock), coupons (code, percent) and orders. To keep authentication out of this post, we read the user from the X-User-Id header; in a real application that comes from a cookie or a token, and we will build that in the A07 posts. Here is the endpoint:

app.MapPost("/orders", async (OrderRequest req, HttpContext http) =>
{
    int userId = int.Parse(http.Request.Headers["X-User-Id"]!);
    using SqliteConnection conn = Db.Open();

    Book? book = await conn.QuerySingleOrDefaultAsync<Book>(
        "SELECT id, title, price, stock FROM books WHERE id = @id", new { id = req.BookId });
    if (book is null) return Results.NotFound();
    if (book.Stock < req.Quantity) return Results.Conflict(new { error = "Out of stock" });

    decimal total = book.Price * req.Quantity;
    if (req.CouponCode is not null)
    {
        int? percent = await conn.QuerySingleOrDefaultAsync<int?>(
            "SELECT percent FROM coupons WHERE code = @code", new { code = req.CouponCode });
        if (percent is not null) total -= total * percent.Value / 100;
    }

    await Task.Delay(200); // pretend we call the payment provider

    await conn.ExecuteAsync(
        "UPDATE books SET stock = stock - @q WHERE id = @id", new { q = req.Quantity, id = req.BookId });
    int orderId = await conn.ExecuteScalarAsync<int>(
        "INSERT INTO orders (user_id, book_id, quantity, total) VALUES (@userId, @bookId, @q, @total) RETURNING id",
        new { userId, bookId = req.BookId, q = req.Quantity, total });
    return Results.Created($"/orders/{orderId}", new { orderId, total });
});
...
public sealed record OrderRequest(int BookId, int Quantity, string? CouponCode);

The queries use Dapper and are parameterized, in line with everything in the Dapper series. The price comes from the database, not from the client, which is also good. The Task.Delay(200) line stands in for a payment service call in real life. Start the app with dotnet run --urls http://localhost:5141 and let's try it.

Three Attacks, Three Missing Requirements

First, a negative quantity. Clean Code costs 45.90 and there are 10 copies in stock:

$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":-3}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:02:00 GMT
Server: Kestrel
Location: /orders/1
Transfer-Encoding: chunked

{"orderId":1,"total":-137.7}

The total is -137.7, and the stock check passed too, because 10 < -3 is false. stock - (-3) raised the stock to 13. If the payment side reads that amount as a refund, the system turns into a machine that hands out money. Second, reusing the coupon:

$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":2,"total":41.31}
$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":3,"total":41.31}

The code checks whether the coupon exists, but it cannot check whether it was used, because the table has no column to hold that. Third, a race condition. There is a single copy of The Pragmatic Programmer, and two users order it at the same time:

# Send both requests at once and wait for both
$ curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":2,"quantity":1}' & \
  curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 8" -d '{"bookId":2,"quantity":1}' & wait
{"orderId":4,"total":52}{"orderId":5,"total":52}
$ curl http://localhost:5141/books/2
{"id":2,"title":"The Pragmatic Programmer","price":52,"stock":-1}

Both requests read the stock as 1, both passed the check and both decremented it. The 200 milliseconds between the read and the write make the window visible, but the window is always there; the delay only widens it. This kind of bug is called "time-of-check to time-of-use": the world changes between the moment you check and the moment you act.

All three problems share a root: a requirement nobody wrote down at design time. "Quantity is between 1 and 10", "a coupon is used once", "stock never goes below zero". These are sentences that should come up while the feature is being discussed, not in code review.

STRIDE and Abuse Cases: Reading the Design With Bad Intent

Threat modeling is the disciplined form of reading a system through the eyes of someone with bad intent. One of the best-known frameworks is Microsoft's STRIDE, named after the initials of six threat types. Applied to the Bookstore order flow, it produces a handful of concrete questions rather than a big table.

Spoofing: who sends the X-User-Id header, the client? Tampering: which client-supplied field affects the total? Repudiation: if a user says "I didn't place this order", what evidence do we have? Information disclosure: does the coupon check endpoint let someone guess valid coupons? Denial of service: what happens if someone sends a thousand order requests per second? Elevation of privilege: can a regular user attach someone else's coupon or order to their own account?

An abuse case is the reverse of a user story. Next to "as a user I want to add a book to my cart" you write "as an attacker I want to raise my balance with a negative quantity" and "as an attacker I want to use the same coupon a hundred times". Each abuse case becomes an acceptance criterion, and each acceptance criterion becomes a test. A half-hour session would have caught all three flaws above before any code was written.

The Fixed Design

The data model comes first. The fact that a coupon is single-use goes into the table as a column:

CREATE TABLE coupons (code TEXT PRIMARY KEY, percent INTEGER NOT NULL, redeemed_by INTEGER NULL);

Then input validation. The built-in minimal API validation in .NET 10 reads DataAnnotations attributes; all you need to do is register the service:

builder.Services.AddValidation();
...
public sealed record OrderRequest(
    int BookId,
    [Range(1, 10)] int Quantity,
    [StringLength(32)] string? CouponCode);

The most important change is moving the check and the change into the same SQL statement. Instead of reading first and writing later, we put the condition in the WHERE clause and look at how many rows were affected. The query that reads the price and the 200 millisecond delay stay exactly as they were; what changes is everything after them:

using SqliteTransaction tx = conn.BeginTransaction();
// Check and change in one statement: if stock is short, no row is updated
int reserved = await conn.ExecuteAsync(
    "UPDATE books SET stock = stock - @q WHERE id = @id AND stock >= @q",
    new { q = req.Quantity, id = req.BookId }, tx);
if (reserved == 0) return Results.Conflict(new { error = "Out of stock" });

decimal total = price.Value * req.Quantity;
if (req.CouponCode is not null)
{
    // The coupon is bound to this user only if nobody has used it yet
    int? percent = await conn.QuerySingleOrDefaultAsync<int?>(
        "UPDATE coupons SET redeemed_by = @userId WHERE code = @code AND redeemed_by IS NULL RETURNING percent",
        new { userId, code = req.CouponCode }, tx);
    if (percent is null) return Results.Conflict(new { error = "Coupon is invalid or already used" });
    total -= total * percent.Value / 100;
}

int orderId = await conn.ExecuteScalarAsync<int>(
    "INSERT INTO orders (user_id, book_id, quantity, total) VALUES (@userId, @bookId, @q, @total) RETURNING id",
    new { userId, bookId = req.BookId, q = req.Quantity, total }, tx);
tx.Commit();

Decrementing the stock, binding the coupon and writing the order all happen in one transaction. If the coupon is rejected, the method returns without calling Commit, the using rolls the transaction back when it disposes it, and the stock we just decremented comes back. Let's repeat the same three experiments:

$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":-3}'
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
...

{"title":"One or more validation errors occurred.","errors":{"Quantity":["The field Quantity must be between 1 and 10."]}}
$ curl -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
{"orderId":1,"total":41.31}
$ curl -i -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":1,"quantity":1,"couponCode":"WELCOME10"}'
HTTP/1.1 409 Conflict
...

{"error":"Coupon is invalid or already used"}
$ curl http://localhost:5141/books/1
{"id":1,"title":"Clean Code","price":45.9,"stock":9}

Look at the last line: there were two order attempts, but the stock went down by only one. The stock taken by the second attempt came back when its transaction was rolled back. The race condition experiment now produces a single order as well:

$ curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 7" -d '{"bookId":2,"quantity":1}' & \
  curl -s -X POST http://localhost:5141/orders -H "Content-Type: application/json" -H "X-User-Id: 8" -d '{"bookId":2,"quantity":1}' & wait
{"orderId":2,"total":52}{"error":"Out of stock"}
$ curl http://localhost:5141/books/2
{"id":2,"title":"The Pragmatic Programmer","price":52,"stock":0}

The delay is still there, but it no longer hurts, because there is no window left between "is there enough stock?" and "decrement the stock"; the database does both in one step.

Rate Limiting: A Cap on Trial and Error

The information disclosure question from STRIDE led us to the coupon check endpoint: GET /coupons/{code} tells you whether a code is valid. If requests are unlimited, someone can try every possible code in turn. Since ASP.NET Core 7 the framework has a built-in rate limiting middleware:

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.AddPolicy("coupon-check", http =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: http.Request.Headers["X-User-Id"].ToString(),
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0
            }));
    options.OnRejected = (context, _) =>
    {
        if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
            context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
        return ValueTask.CompletedTask;
    };
});
WebApplication app = builder.Build();
app.UseRateLimiter();
...
app.MapGet("/coupons/{code}", async (string code) =>
{
    using SqliteConnection conn = Db.Open();
    bool valid = await conn.ExecuteScalarAsync<bool>(
        "SELECT EXISTS (SELECT 1 FROM coupons WHERE code = @code AND redeemed_by IS NULL)", new { code });
    return Results.Ok(new { valid });
}).RequireRateLimiting("coupon-check");

Each user gets five attempts per minute. The sixth request is rejected:

$ for i in 1 2 3 4 5 6; do curl -s -o /dev/null -w "%{http_code} " http://localhost:5141/coupons/SPRING$i -H "X-User-Id: 9"; done
200 200 200 200 200 429
$ curl -i http://localhost:5141/coupons/SPRING7 -H "X-User-Id: 9"
HTTP/1.1 429 Too Many Requests
Content-Length: 0
Date: Fri, 06 Nov 2026 19:02:03 GMT
Server: Kestrel
Retry-After: 60

Two details matter. The default RejectionStatusCode is 503, so if you don't set it, a rejected client is told "the server is down"; 429 is the correct answer. The Retry-After header doesn't appear by itself either; you have to write it from the limiter's metadata in OnRejected.

Common Pitfalls

The first pitfall is putting business rules only in the UI. A min="1" on an Angular form guides the user but doesn't protect the API; curl never sees the form. The rule belongs in the API, and where possible in the database too (as a CHECK constraint, for example).

The second is trying to fix a race condition with lock. A lock only works inside a single process; the moment your app runs as two instances, the protection is gone. If the database owns the shared state, ask the database for atomicity as well: a conditional UPDATE, a unique constraint or a suitable isolation level.

The third is rate limiting by IP only. A hundred users in the same office share one IP, while an attacker can use a hundred different IPs. Picking the partition key by user, account or resource usually makes more sense. Also remember that the counters the rate limiter keeps in memory are separate for each instance.

The fourth is treating threat modeling as a one-off document. If the model isn't updated when a new feature arrives, it stops describing reality after the first sprint. Its most productive form is a five-minute "how would I abuse this?" round in the meeting where each new feature is discussed.

Checklist

Before turning a feature into code, ask these questions. Does every input have a defined valid range, and is it validated on the server? For every single-use, limited or monetary resource, has the "two requests arrive at the same moment" scenario been considered? Are the check and the change in the same atomic operation?

Does every endpoint that invites trial and error (coupons, OTPs, password resets, invite codes) have rate limiting, and does a rejected request return 429? Are values such as price, discount and role computed only on the server? Is there at least one abuse case next to every user story, and is that abuse case guarded by a test?

A Small Experiment

★☆☆ Run the vulnerable version and send "quantity": 0. Watch what comes back and what changes in the stock. Then send the same request to the fixed version and read the message from the Range attribute.

★★☆ Add CHECK (quantity BETWEEN 1 AND 10) to the orders table, temporarily remove the AddValidation() line and send a negative quantity. See which layer stops the request and with what error message; think about why it helps to have the rule in two places.

★★★ Add a second policy that limits each user to three orders per minute on the order endpoint. Then write STRIDE's six questions for the GET /orders/{id} endpoint and come up with at least two abuse cases.

In this post rate limiting protected coupon codes. In the next one we move to the most dangerous form of the same trial-and-error problem, the login screen: under A07 Authentication Failures we look at brute force and credential stuffing, Identity's lockout feature, MFA and NIST's current password rules.