API business logic abuse: requests that are valid but wrong

Akamai's Apps, APIs, and DDoS 2026 report counted an average of 258 API attacks per enterprise in 2025, up 113% from 121 in 2024. The same report found that 61% of API attacks in 2025 involved unauthorized workflows and abnormal activity, up from 30% in 2024.

Akamai's data comes from enterprise traffic. The pattern scales down without trouble. A five-person SaaS with a signup flow, a checkout and a search endpoint has the same exposure, and usually less logging to catch it with.

Why your existing checks miss it

Business logic abuse uses your API as built, in an order or at a volume you didn't plan for. Every request carries a valid token. Every body passes validation. The response is a 200.

That leaves most of the usual tools with nothing to look at. A firewall injection rule matches payloads like ' OR 1=1, and there aren't any. Your error tracker records exceptions, and nothing threw. Uptime checks pass. The problem only shows up in the rate and order of requests per user, which most apps don't record.

OWASP files the general case as API6:2023 Unrestricted Access to Sensitive Business Flows and the cross-tenant case as API1:2023 Broken Object Level Authorization.

Four examples from a small SaaS

The same trial claimed dozens of times

Someone scripts signups with throwaway addresses and redeems your trial or launch coupon from each new account. Each request is a normal POST /signup followed by POST /coupons/redeem. What gives it away is volume from one IP range or one device, and accounts that never do anything after the redeem.

Checkout confirmed without paying

Your frontend calls POST /orders/:id/payment and then POST /orders/:id/confirm. A client that skips straight to confirm gets a 200 if the confirm handler only checks that the order exists and belongs to the caller.

Scraping through search

GET /search?q=a&page=1, then page 2, page 3, all night, from one logged-in account. Search is a legitimate endpoint and each page is cheap. Added up, it's a copy of your catalogue plus database load you pay for.

Reading another tenant's records

A logged-in user changes /projects/1042 to /projects/1043 and gets someone else's project, because the handler checked that the caller was signed in and never checked who owns the project.

How to spot it

Tag every request with the user id and tenant id. Without them in your logs you can't ask what one account did in the last hour, and that's the first question in all four examples.

Write down the expected order of calls for your riskiest flows. For most SaaS apps that's signup, checkout and export. A plain list in the repo is enough:

signup: POST /signup -> POST /verify-email -> POST /onboarding checkout: POST /orders -> POST /orders/:id/payment -> POST /orders/:id/confirm export: POST /exports -> GET /exports/:id -> GET /exports/:id/download 

Then alert on per-user request rates for the endpoints in those flows (and search), and on any request that skips a step. The cross-tenant case needs an ownership check in the handler itself; the tags let you find who tried it, by searching for requests where the tenant in the URL isn't the caller's.

Code: a per-user limit and a step-order check

This assumes an Express app where your auth middleware sets req.user and express-session is installed.

// abuse-guards.js const WINDOW_MS = 60_000; const hits = new Map(); // `${name}:${userId}` -> { count, start } // Drop expired windows so the Map doesn't grow forever setInterval(() => { const now = Date.now(); for (const [key, entry] of hits) { if (now - entry.start > WINDOW_MS) hits.delete(key); } }, WINDOW_MS).unref(); function perUserLimit(name, max) { return (req, res, next) => { const key = `${name}:${req.user.id}`; const now = Date.now(); const entry = hits.get(key); if (!entry || now - entry.start > WINDOW_MS) { hits.set(key, { count: 1, start: now }); return next(); } entry.count += 1; if (entry.count > max) { console.warn(JSON.stringify({ reason: 'per_user_rate', name, userId: req.user.id, count: entry.count, })); return res.status(429).json({ error: 'Too many requests' }); } next(); }; } function recordStep(req, orderId, step) { req.session.steps ??= {}; (req.session.steps[orderId] ??= []).push(step); } function requireStep(step) { return (req, res, next) => { const done = req.session.steps?.[req.params.id] ?? []; if (!done.includes(step)) { console.warn(JSON.stringify({ reason: 'step_skipped', missing: step, userId: req.user.id, orderId: req.params.id, done, })); return res.status(409).json({ error: 'Order is not ready to confirm' }); } next(); }; } module.exports = { perUserLimit, recordStep, requireStep }; 

Wiring it up:

const { perUserLimit, recordStep, requireStep } = require('./abuse-guards'); app.get('/search', perUserLimit('search', 120), searchHandler); app.post('/coupons/redeem', perUserLimit('coupon', 3), redeemHandler); app.post('/orders/:id/payment', async (req, res) => { const result = await chargeOrder(req.params.id, req.body); if (result.ok) recordStep(req, req.params.id, 'payment'); res.json(result); }); app.post('/orders/:id/confirm', requireStep('payment'), confirmHandler); 

The counter lives in memory, so each instance keeps its own count. Once you run more than one instance, move it to Redis (INCR plus EXPIRE on a key per user and window) so the limit holds across all of them.

The session check is the quick version. The stronger one reads the order record in confirmHandler and refuses unless your payment provider has marked it paid, since the server's own record of the payment is the one to trust.

Both guards log a reason code, per_user_rate or step_skipped. Count those per hour and alert on a jump.

Checklist

What to log on every request:

  • user id and tenant id
  • method, route pattern and status code
  • a reason code whenever you reject a request on purpose

Flows to map: signup, checkout and export, or your app's equivalents (anything that creates an account, moves money or moves data out).

Alerts to add:

  • one user over your per-minute limit on a flow endpoint or search
  • any step_skipped line

Where ApiTraffic fits

ApiTraffic's SDK has a tag() function you call while handling a request, such as apiTraffic.tag('tenantId', String(req.user.tenantId)). Tags are searchable in the request log (tag:tenantId == "t_42"), so you can pull every request one account made. The built-in firewall's rate limits take a path pattern and can count by tag, so the per-user limit on /search can live in a policy instead of your code. Requests over the limit get a 429 with a Retry-After header.

See what's hitting your API: [link]