API6 Sensitive Business Flows: Identifying Sensitive Flows, Idempotency-Key and Flow-Level Limits
Against API6 you first identify which business flows automation can damage, then give each flow its own rule. A filter that processes repeated POSTs once following the Idempotency-Key draft, a concurrent retry test, enforcing coupon and limited-item rules with database constraints and a conditional UPDATE, RFC 9470-style step-up, and a per-customer flow limit.
Version note: The code in this post was built and run with .NET 10 and EF (Entity Framework) Core 10.0.14; for the Idempotency-Key header it follows version -08 of the IETF (Internet Engineering Task Force) draft, dated 22 October 2026.
Guess First
The North store is going to sell five copies of a signed first edition. Two minutes after the announcement the stock is gone, and four of the five orders come from the same customer, whose account was opened yesterday. The same week a complaint comes from the mobile app: users who tapped "Place order" on a weak connection created two, sometimes three orders. In both incidents the API (Application Programming Interface) worked correctly in a technical sense. No request was unauthorized, no endpoint was open.
That is exactly what API6 (Unrestricted Access to Sensitive Business Flows in the OWASP API Security Top 10 2023) is about: requests that are legitimate one by one turning into business damage through automation or repetition. The basics of business logic flaws and race conditions are in the A06 Insecure Design post of the Web Security (OWASP Top 10) series. Here we look at the API side: identifying which flows are sensitive, processing repeated POSTs once with Idempotency-Key, and putting flow-specific limits in place.
Which Flows Are Sensitive?
The API6 page of OWASP (Open Worldwide Application Security Project) says the problem starts on the business side, not the technical side: first you need to identify which flows would hurt the business if abused. I sat down and made a list for Bookstore. Each row has the flow, the business impact when it's abused through automation, and the defence we build in this post:
| Flow | If abused | Defence |
|---|---|---|
| Limited-stock item | Bots collect the stock, real customers can't buy | Per-customer daily unit limit, atomic stock decrement |
| Coupon | The same coupon is used again and again, margin melts | A unique index in the database for customer and coupon |
| Order creation | Double orders, double charges, stock drift | Idempotency-Key, per-customer flow limit |
| High-value order | Big spending with a hijacked session | Step-up: authentication within the last 5 minutes |
| Sign-up and invites | Fake accounts farming campaigns and coupons | Email verification, device and IP (Internet Protocol) signals, delayed activation |
| Reviews | Mass fake reviews, smearing competitors | Reviews only for purchased items, review before publishing |
I didn't code the last two rows in the lab; I put them in the table because the list of sensitive flows is part of a threat model, not a technical inventory. You need to write this list together with the product owner, because the answer to "how many uses per coupon is normal?" isn't in the code.
Idempotency-Key: Processing the Same Request Once
The classic fix for the mobile app's double orders is an idempotency key. The client generates a unique key for each logical operation and sends the same key when it repeats the request; the server doesn't process the second request with the same key again, it returns the first response. The Idempotency-Key draft of the IETF HTTP (Hypertext Transfer Protocol) API working group standardises this. The draft isn't an RFC (Request for Comments) yet; the latest version I looked at is -08. The rules I took from it: the header value is a structured field string, i.e. written in quotes; if the key is required and missing, 400; if the same key comes with a different payload, 422; if the first request is still being processed, 409. Using a fingerprint to compare payloads and giving keys an expiry are optional in the draft.
public const string Header = "Idempotency-Key";
public static readonly TimeSpan Retention = TimeSpan.FromHours(24);
// Per the draft the value is a structured field string: quoted, at most 255 characters
public static bool TryParse(string? raw, out string key)
{
key = "";
if (raw is not { Length: > 2 and <= 257 } || raw[0] != '"' || raw[^1] != '"')
{
return false;
}
key = raw[1..^1];
return key.All(c => c is >= ' ' and <= '~' and not '"' and not '\\');
}
public static string Fingerprint<T>(T request) =>
Convert.ToHexStringLower(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(request)));
The 255-character limit isn't from the draft, it's my decision: a key of unbounded length would bring the resource consumption problem from the API4 (Unrestricted Resource Consumption) post here. I compute the fingerprint not from the raw body but from the CreateOrderRequest produced by model binding. That way a difference in whitespace or field order inside the JSON (JavaScript Object Notation) doesn't count as a "different request", but quantity going from 1 to 2 does.
The Filter: Record, Fingerprint and Replaying the Response
Keys are kept in a table. The table has a unique index on (TenantId, Principal, Key): the key is bound to the tenant and the user, so Alan's key doesn't collide with Grace's key of the same value, and one user can't replay another user's response. The logic lives in an endpoint filter:
IdempotencyRecord? existing = await store.TryBeginAsync(principal, key, fingerprint, ct);
if (existing is not null)
{
if (existing.Fingerprint != fingerprint)
{
return Results.Problem(statusCode: StatusCodes.Status422UnprocessableEntity,
title: "This Idempotency-Key was used with a different request.");
}
if (existing.StatusCode is null)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict,
title: "A request with this Idempotency-Key is still being processed.");
}
// The same request came again: don't redo the work, return the first response as is
http.Response.Headers["Idempotent-Replayed"] = "true";
if (existing.Location is not null)
{
http.Response.Headers.Location = existing.Location;
}
return Results.Text(existing.ResponseBody ?? "", "application/json", statusCode: existing.StatusCode);
}
object? result;
try
{
result = await next(context);
}
catch
{
await store.AbandonAsync(principal, key, CancellationToken.None);
throw;
}
// Only a successful result is stored: responses such as a 401 step-up or "out of stock" must be retryable with the same key
if (result is IStatusCodeHttpResult { StatusCode: >= 200 and < 300 } status)
{
string? body = result is IValueHttpResult { Value: { } value } ? JsonSerializer.Serialize(value,
JsonSerializerOptions.Web) : null;
string? location = result is Created<OrderSummary> created ? created.Location : null;
await store.CompleteAsync(principal, key, status.StatusCode!.Value, body, location, ct);
}
else
{
await store.AbandonAsync(principal, key, ct);
}
return result;
TryBeginAsync first looks up the key and, if it isn't there, adds a record in the "processing" state. The real guarantee is the unique index: even if two requests both see "not there" at the same moment, the second INSERT fails and that request reads the existing record. The record is written with a short-lived context separate from the one the endpoint uses; that way we can delete the record if the endpoint fails, and the client can retry with the same key.
Which results get stored is a deliberate decision. In the first version I stored every response below 500, and the curl test showed a problem: the 401 returned by step-up was stored too. When the user signed in again and retried with the same key, the server replayed the 401 to them. The "out of stock" 409 was in the same situation. Now only 2xx responses are stored; anything else deletes the record. The Idempotent-Replayed header isn't in the draft; it's something I've seen in Stripe's API, and it helps the client and the logs tell a replay apart. With curl:
$ curl -s -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -d "$BODY"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"A valid Idempotency-Key header is required.","status":400,"traceId":"00-1dc4e47632135975bb59da5266598f31-ff66d4c81be9b574-00"}
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "6f1c2a52-6a3e-4c7e-9a43-1b2f6a0d9c11"' -d "$BODY"
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sun, 08 Nov 2026 06:10:51 GMT
Server: Kestrel
Location: /orders/01a1b9c7-f77f-7a6d-a143-ca474cfff09d
Transfer-Encoding: chunked
{"id":"01a1b9c7-f77f-7a6d-a143-ca474cfff09d","status":"Pending","total":54.9,"createdAt":"2026-11-08T06:10:52.408381Z"}
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "6f1c2a52-6a3e-4c7e-9a43-1b2f6a0d9c11"' -d "$BODY"
HTTP/1.1 201 Created
Content-Length: 119
Content-Type: application/json
Date: Sun, 08 Nov 2026 06:10:51 GMT
Server: Kestrel
Location: /orders/01a1b9c7-f77f-7a6d-a143-ca474cfff09d
Idempotent-Replayed: true
{"id":"01a1b9c7-f77f-7a6d-a143-ca474cfff09d","status":"Pending","total":54.9,"createdAt":"2026-11-08T06:10:52.408381Z"}
The second response is identical to the first: the same order id, the same Location. When I sent quantity: 2 with the same key, I got 422: "This Idempotency-Key was used with a different request.".
Concurrent Retries
The really hard scenario isn't a sequential retry but a concurrent one: on a weak connection the client's retry logic sends the second request before the first has finished. We force that with a test: ten requests with the same key at the same time.
[Fact]
public async Task Ten_concurrent_requests_with_one_key_create_exactly_one_order()
{
HttpClient grace = factory.CreateClientFor("grace");
CreateOrderRequest body = new(SeedData.NorthBook, 1, "1 Test Street");
string key = Guid.NewGuid().ToString();
int before = await CountOrdersAsync("grace");
HttpResponseMessage[] responses = await Task.WhenAll(
Enumerable.Range(0, 10).Select(_ => grace.SendAsync(Order(body, key), Ct)));
TestContext.Current.TestOutputHelper?.WriteLine(string.Join(" ", responses.Select(r => (int)r.StatusCode)));
Assert.All(responses, r => Assert.Contains((int)r.StatusCode, new[] { 201, 409 }));
Assert.Equal(before + 1, await CountOrdersAsync("grace"));
}
The status codes in the test output differ slightly on every run. From two runs:
409 409 409 409 409 409 409 201 409 409
201 409 201 409 409 409 409 409 201 409
The second run has three 201s, yet only one order was created in the database: the first 201 is the real operation, the other two are requests that arrived after the first had completed and replayed the stored response. The ones that got 409 arrived while the first request was still being processed; per the draft the client can retry them unchanged. The test's real claim isn't the distribution of status codes but that the order count goes up by one.
Flow-Level Rules: Coupons, Limited Items and Stock
Idempotency solves repeats of the same request, but not different requests with different keys. A bot generates a new key every time anyway. For that you need the flow's own rules, and the most reliable place for those rules is the database.
The coupon rule is "each customer uses each coupon once", and I enforce it with a unique index instead of an if in application code. A check like if (!await db.CouponRedemptions.AnyAsync(...)) lets two concurrent requests both see "unused"; a unique index doesn't:
// Flow rules live in the database: the same customer can't use the same coupon twice
modelBuilder.Entity<CouponRedemption>().HasIndex(c => new { c.TenantId, c.CustomerId, c.Code }).IsUnique();
The limited item has two rules: at most one unit per customer in the last 24 hours, and stock never going negative. The second is done with a single conditional UPDATE; two requests can't see the same last unit, because the Stock >= quantity check and the decrement are in the same SQL (Structured Query Language) statement:
DateTime now = time.GetUtcNow().UtcDateTime;
if (book.MaxPerCustomerPerDay is int max)
{
// Limited item: at most "max" units per customer in the last 24 hours
DateTime since = now.AddDays(-1);
int bought = await db.Orders
.Where(o => o.CustomerId == user.Id && o.CreatedAt >= since && o.Status != OrderStatus.Cancelled)
.SelectMany(o => o.Items)
.Where(i => i.BookId == book.Id)
.SumAsync(i => i.Quantity, ct);
if (bought + request.Quantity > max)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Purchase limit reached for this item.");
}
}
await using IDbContextTransaction transaction = await db.Database.BeginTransactionAsync(ct);
// The stock decrement is a single conditional UPDATE: two requests can't get the same last unit
int reserved = await db.Books
.Where(b => b.Id == book.Id && b.Stock >= request.Quantity)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Stock, b => b.Stock - request.Quantity), ct);
if (reserved == 0)
{
return Results.Problem(statusCode: StatusCodes.Status409Conflict, title: "Out of stock.");
}
The stock decrement, the order and the coupon record are in the same transaction. If the coupon hits the unique index, SaveChangesAsync throws, the transaction is disposed without a commit and the stock decrement is rolled back too. The tests check this: after the order that tries to use the coupon a second time and gets 409, the book's stock stays at its value after the first order; and after the signed book order rejected because of the limit, the stock is 4, not 3. The per-customer limit check, on the other hand, is a read outside the transaction and could in theory be exceeded by two concurrent requests; if you need it to be exact, you have to keep the limit in a counter table with a conditional UPDATE. In the lab I found that risk acceptable together with the per-customer flow limit below.
Step-Up and a Flow-Specific Rate Limit
High-value orders carry another risk: a hijacked session. The token is valid, the user is right, but the session has been open for hours and may be in someone else's hands. Here we use step-up authentication: orders above 500 require the user to have authenticated within the last five minutes. The auth_time claim in the token carries that time. The response format comes from RFC 9470 (OAuth 2.0 Step Up Authentication Challenge Protocol): the insufficient_user_authentication error and the max_age parameter give the client enough information to send the user back to the sign-in screen.
public static IResult Challenge(HttpContext context)
{
context.Response.Headers.WWWAuthenticate =
$"Bearer error=\"insufficient_user_authentication\", error_description=\"Recent sign-in required\", max_age={(int)MaxAge.TotalSeconds}";
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Please sign in again to place this order.");
}
$ ALAN_OLD=$(dotnet run --project tools/Bookstore.Orders.TokenTool -- alan 900)
$ curl -i -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN_OLD" -H "Content-Type: application/json" -H 'Idempotency-Key: "big-1"' -d '{"bookId":"0197f003-0000-7000-8000-000000000001","quantity":10,"shippingAddress":"1 Test Street"}'
HTTP/1.1 401 Unauthorized
Content-Type: application/problem+json
Date: Sun, 08 Nov 2026 06:11:21 GMT
Server: Kestrel
Transfer-Encoding: chunked
WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="Recent sign-in required", max_age=300
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.2","title":"Please sign in again to place this order.","status":401,"traceId":"00-94389b24536f3788c020396ef4652809-7a57d2f97bb56a3b-00"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19517/orders -H "Authorization: Bearer $ALAN" -H "Content-Type: application/json" -H 'Idempotency-Key: "big-1"' -d '{"bookId":"0197f003-0000-7000-8000-000000000001","quantity":10,"shippingAddress":"1 Test Street"}'
201
With a fresh token the same key got 201; that's why the 401 isn't stored. In the lab the TokenTool mints the token and we can move auth_time back as far as we like; in a real system the re-authentication flow and MFA (Multi-Factor Authentication) are in the MFA post of the Authentication and Authorization series.
The last layer is a flow-specific rate limit. The limits in the API4 post protected expensive resources; this one protects a business flow: a customer can make at most twenty order attempts in ten minutes, retries included. A single limit for the whole API would lose this flow, the one that affects orders most, among hundreds of other read requests.
// Flow-level limit: at most 20 order attempts per customer in 10 minutes (retries included)
options.AddPolicy(PlaceOrderPolicy, context => RateLimitPartition.GetFixedWindowLimiter(
$"order:{context.User.FindFirstValue(LabClaims.Subject) ?? "anonymous"}",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(10), QueueLimit = 0 }));
The seven-test SensitiveFlowTests class covers all these rules: retries and replay, a changed payload, a missing key, concurrency, coupons, limited items and stock, step-up and the flow limit. All 131 tests are green.
Production Notes
Idempotency records grow. You need a scheduled job that deletes records past the 24-hour retention; the draft also asks that, if keys expire, this policy be published. The retention should be longer than the client's longest retry window.
Signals for detecting automation don't decide anything on their own, but together they mean something: the account's age, many accounts from the same device or IP block, speed and regularity unrealistic for a human, arriving at the payment endpoint directly without ever viewing the cart. Turning these signals into a score at order time and moving orders above a threshold into "awaiting review" instead of rejecting them outright lowers the cost of false positives. Delayed processing follows the same logic: in a limited-item sale, collecting orders for a few minutes and then allocating reduces the advantage of the fastest bot.
CAPTCHA (Completely Automated Public Turing test to tell Computers and Humans Apart) is an option, but not a cheap one. It creates accessibility problems for users with visual or motor impairments, lowers conversion, and often can't be applied in a mobile client that calls the API directly. On top of that, CAPTCHA-solving services are cheap. Showing a CAPTCHA not to everyone but to flows and requests with a high risk score is a more balanced use.
We discussed in the API4 post that the rate limiter is in-memory; the same warning applies to flow limits. In a multi-instance deployment, coupon and stock rules stay safe because they live in the database, while the flow limit becomes approximate.
Common Pitfalls
The first pitfall is putting a single rate limit on the whole API and not seeing flow-level risk. 100 requests a minute is generous for listing products, and for buying a limited-stock item it's the same as unlimited.
The second is looking up idempotency keys by key alone. If the key isn't bound to the user and the tenant, someone who guesses or sees a user's key can replay their response and read the order details. That is BOLA (Broken Object Level Authorization) in the idempotency table.
The third is storing every response. Storing error responses permanently breaks the attempt the user makes with the same key after fixing the problem. Choose consciously which results are "final".
The fourth is enforcing a business rule with a read in application code followed by a write. The gap between "has the coupon been used?" and inserting the record is exactly the race condition we saw in the Web Security series. The rule should live in the database as a constraint or a conditional UPDATE.
A Small Experiment
★☆☆ In the lab, place a valid order with an Idempotency-Key, then try again with the same key after adding couponCode. Which status code did you get, and why?
★★☆ Write a BackgroundService that deletes expired idempotency records and test, using TimeProvider, that an expired record is deleted.
★★★ Make the per-customer daily limit check robust against concurrent requests: a counter table with a unique index on (TenantId, CustomerId, BookId, Day) and a conditional UPDATE. Show with a test that only one of five simultaneous requests from the same customer succeeds.
In the next post we open up to the outside world: API7 (Server Side Request Forgery) and API10 (Unsafe Consumption of APIs). Validating the webhook URL (Uniform Resource Locator) a user provides at connection time, checking the signature of incoming webhooks, and treating third-party API responses with as much suspicion as user input.