Most API security guides assume a clean environment and a patient timeline. Production gives you neither. This article describes a layered architecture for API abuse defense — client integrity tokens, cohort-based rate limiting, and behavioral scoring — and explains why each layer sits where it does.
The problem shape
Two abuse patterns dominate modern API traffic:
- Distributed credential stuffing. Attackers rotate source IPs quickly (often every 30–60 seconds) and mimic legitimate mobile clients, so IP-reputation and volumetric rules never accumulate enough signal on any single address.
- Low-and-slow scraping. Bots imitate a real user session for minutes at a time, walking through pricing, catalog, or profile endpoints at human-plausible rates.
Traditional WAF rules are usually tuned for volumetric DDoS and injection attacks. They perform poorly against both patterns above because the request shape looks legitimate — the only anomalies are in aggregate behavior and in client provenance.
The engineering constraint that shapes everything else: any defense that adds a JavaScript challenge or a synchronous round trip to the client will hurt mobile users on high-latency networks. A challenge that costs 80 ms on a desktop connection can cost 1.4–1.8 seconds on a congested 3G link, and users abandon flows long before that.
Why the obvious options fall short
JavaScript challenges. Effective against headless browsers that don't execute JS, but they add a full round trip. On mobile SDK traffic — which doesn't run a browser at all — a challenge either breaks the client or forces you to maintain an allowlist, which attackers then target.
Volumetric scrubbing services. These absorb large floods but do nothing for credential stuffing or scraping, because the traffic volume per source is small and the requests are well-formed. They also tend to be priced for the flood case, so the cost is hard to justify if your actual problem is 200 req/min of credential stuffing from 50,000 IPs.
Per-IP rate limiting in Redis. The first naive implementation usually looks like this: one sorted-set key per IP, trimmed on a rolling window. It works until attackers move to IPv6, where the address space is large enough that each request can come from a distinct address. Memory then grows with the number of distinct addresses seen, and without aggressive TTLs the keyspace can grow faster than the request rate.
The lesson from all three: a single heavyweight filter at one layer cannot separate good from bad traffic when the bad traffic is designed to look like the good traffic. The defense has to be a pipeline.
The architecture: cheap stateless checks at the edge, stateful checks in the API
The design principle is to triage early and cheaply:
- Client-side integrity tokens to establish that a request came from a real client build, not a script.
- Adaptive rate limiting bucketed by user cohort rather than by IP.
- Behavioral fingerprinting at the CDN edge to score request sequences without executing JavaScript.
Stateless verification (signature checks, token expiry) belongs at the edge, where it costs microseconds and never touches origin capacity. Stateful logic (rolling windows, per-account anomaly detection) belongs in the API layer, where you control cost, data residency, and observability.
Layer 1: client integrity tokens
A client integrity token (CIT) is a short-lived signed assertion that the request originated from a legitimate client build. A typical construction combines:
- A hardware-backed public key (Android Keystore or iOS Secure Enclave) so the key can't be trivially extracted from a repackaged app.
- A device fingerprint hash (Canvas, WebGL, AudioContext) as a secondary signal.
- A monotonic counter or nonce to prevent replay.
The token is signed with ECDSA-P256 and expires quickly — 15 minutes is a reasonable starting point. Clients send it in a custom header, for example X-CIT. The edge function verifies the signature against a public key embedded in the client build and rejects invalid or expired tokens before they reach the origin.
// Edge function (Node.js runtime) — verify CIT before forwarding
export async function handler(event) {
const { request } = event;
const cit = request.headers['x-cit'];
if (!cit) return deny();
try {
const payload = verifyECDSA(cit, PUBLIC_KEY);
if (payload.exp < Date.now()) return deny();
if (payload.nonceUsed.has(request.clientIp)) return deny();
payload.nonceUsed.add(request.clientIp);
request.cit = payload; // passed downstream
} catch (e) {
return deny();
}
return request;
}
Two implementation notes that matter in practice:
- Nonce tracking is stateful. The
nonceUsedset above is illustrative; in production you either keep it in a low-latency store with a TTL equal to the token lifetime, or you rely on the monotonic counter plus a short expiry window and accept a small replay surface. - Key rotation needs a plan. Embedding a single public key in the client build means you can't rotate it without shipping an app update. Support at least two valid keys at any time so rotation is a server-side operation.
The cost of this layer is a signature verification per request — microseconds on modern runtimes — and it eliminates the entire class of clients that can't produce a valid signature.
Layer 2: adaptive rate limiting by cohort
Static per-IP limits fail against distributed sources. The alternative is to bucket requests by properties that are expensive for an attacker to fake at scale:
- Account age (new, regular, veteran)
- Device class (iOS, Android, web mobile, web desktop)
- Region (based on the client's declared locale or a coarse geo signal, not on IP)
The edge function reads the verified CIT payload and selects a bucket. Each bucket has a dynamic limit over a rolling window. A representative table:
| Bucket | Limit (req/min) | Burst (req) |
|---|---|---|
| new_africa_web | 30 | 60 |
| regular_asia_mob | 120 | 240 |
| veteran_eu_mob | 300 | 600 |
These numbers are illustrative — the correct values come from your own traffic distribution, which the measurement section below describes how to collect.
The limiter itself is a sorted-set window in Redis:
-- Redis Lua script for adaptive rate limit
local bucket = KEYS[1] -- e.g. "bucket:regular_asia_mob"
local limit = tonumber(ARGV[1]) -- 120
local burst = tonumber(ARGV[2]) -- 240
local window = 600 -- 10 min
local now = tonumber(ARGV[3]) -- current timestamp
redis.call('ZREMRANGEBYSCORE', bucket, 0, now - window) -- trim old
local count = redis.call('ZCARD', bucket)
if count >= burst then
return {0, "burst_exceeded"}
elif count >= limit then
return {count, "rate_limited"} -- allow, but warn downstream
else
redis.call('ZADD', bucket, now, now)
return {count + 1, "ok"}
end
Three things to get right:
- Always set a TTL on the bucket key. Without
EXPIRE, keys for cohorts that go quiet are never reclaimed. Set the TTL to at least twice the window length so a burst of activity near the boundary isn't truncated. - Bucket on the account, not the IP. A distributed attacker controls many IPs but must reuse accounts (or create new ones, which lands them in the
new_*bucket with the tightest limit). Bucketing on account identity collapses the address-space problem. - Return a retry hint. A 429 with a
Retry-Afterheader lets well-behaved clients back off gracefully instead of hammering the limiter.
Layer 3: behavioral scoring at the edge
Behavioral bot detection assigns each request a score based on request sequence patterns — timing, header consistency, navigation order — rather than on a single request's content. Managed services in this category expose the score as a header (commonly something like X-Behavior-Score) that your edge function can read and act on.
The integration pattern:
- Configure the bot management service to emit the score header on every request.
- At the edge, forward only requests scoring above a threshold; return 403 with a retry hint for the rest.
- Log the score alongside the request so you can tune the threshold against real data.
A threshold of 0.7 (on a 0–1 scale where 1 is almost certainly human) is a common starting point, but the right value depends entirely on your false-positive tolerance. The measurement section below explains how to find it.
The key advantage over JavaScript challenges is latency: behavioral scoring is a server-side decision made from request metadata, so it adds no client round trip. Behavioral rules typically add well under a millisecond to edge processing.
Measuring false positives — and why it's the only number that matters
Every bot defense trades false positives for false negatives. The only way to set thresholds honestly is to measure the false-positive rate per cohort, because a threshold that's fine for desktop web may be unacceptable for a mobile SDK.
What to instrument:
- Per-cohort request counts tagged with the bot score and the final decision (allowed / blocked / challenged).
- Per-cohort block rates over a rolling window.
- Support-ticket volume tagged with the cohort and the timestamp, so you can correlate a threshold change with a support spike.
A practical workflow:
- Run the bot scoring layer in monitor mode for at least one full traffic cycle (a week is typical, longer if you have weekly seasonality). Log the score and the decision but don't enforce.
- Compute the block rate per cohort at several candidate thresholds.
- Pick the threshold where the block rate on known-good cohorts (your own test devices, internal service accounts, monitored customer segments) stays below your tolerance. A common target is under 0.5% per cohort, but the number is a business decision, not a technical one.
- Enforce, and keep the monitor-mode logging on so you can detect drift.
If you don't have a labeled "known-good" cohort, build one: enroll a set of internal test devices and a small opt-in group of real users, and treat their traffic as ground truth.
A worked example: choosing a threshold
Suppose you run monitor mode for a week and observe the following (figures illustrative, not measured):
- Mobile SDK traffic: 8.2M requests, block rate 0.04% at threshold 0.7.
- Web mobile (real users): 3.1M requests, block rate 0.12% at threshold 0.7.
- Known scraper traffic (from a honeypot endpoint): 11.7M requests, block rate 99.8% at threshold 0.7.
The web mobile cohort is the binding constraint: 0.12% of 3.1M requests is roughly 3,700 blocked requests per week. If your support capacity can absorb that, 0.7 is workable. If not, raise the threshold to 0.6 and re-measure — you'll trade some scraper detection for fewer false positives.
The point of the example is the method, not the numbers. Run it on your own traffic.
Failure modes to plan for
Fingerprint drift. Bot vendors ship new headless drivers regularly. A cohort that was clean last month can become noisy this month. The mitigation is continuous monitoring, not a one-time threshold.
IPv6 address-space exhaustion of per-IP state. If any part of your stack still keys on IP, plan for the keyspace to grow with the number of distinct addresses. Prefer account- or session-keyed buckets, and set TTLs on every key.
Token replay. A signed token with a long expiry is a replayable credential. Keep expiries short, track nonces where you can, and rotate signing keys on a schedule.
Edge function size and complexity limits. Edge runtimes have tighter constraints than origin runtimes — smaller bundle limits, fewer available APIs, no persistent state. Keep edge functions small and push complex logic to the origin. If a function grows past a few hundred lines, it probably belongs in the API layer.
Challenge-induced churn. Any defense that adds a client round trip will show up as conversion loss on high-latency networks. If you must challenge, do it only for cohorts where the expected abuse cost exceeds the expected churn cost.
A decision checklist
Before adding a layer, answer these:
- What's the abuse pattern? Credential stuffing, scraping, and volumetric floods need different defenses. Don't buy a flood solution for a stuffing problem.
- Can the client change? If you control the mobile app, CITs are viable. If you're defending a public API consumed by third parties, you can't require client-side changes.
- What's the latency budget? Any defense that adds a client round trip consumes part of it. Stateless edge checks consume almost none.
- What's the false-positive tolerance? This is a business decision. Get it in writing before you pick a threshold.
- Where does state live? Stateful checks belong where you control cost and data residency. Stateless checks belong at the edge.
- How will you detect drift? If you can't monitor block rates per cohort, you can't tune thresholds safely.
How to apply this in the next 30 minutes
Pick one high-value endpoint — login or pricing are the usual candidates — and enable real-time logging on it. Then run a query that groups requests by minute, cohort, and response status. Look for two signatures:
- A flat line of 429s across many source addresses. That's distributed credential stuffing.
- Traffic at 1.5× normal with a low success rate. That's likely scraping.
If you see either, you have the data to decide which layer to add first: CITs for stuffing, behavioral scoring for scraping, and cohort-based rate limiting when you need to cap both without breaking mobile clients. Start with the cheapest stateless layer and add state only when the data justifies it.
Related reading
- API attacks rising 40%: the 3 patterns to block now
- API abuse 2026: 5 attacks we missed until costs spiked
- Portfolio that hires: show constraints, not code