Someone told you to add velocity checks. What they meant is: count events per key per window, and do something when the count crosses a line. That is the whole mechanism. The engineering is in three choices, and everyone argues about the threshold, which is the least important of the three. The key you count on decides whether you catch the attacker or block a university. The window decides which attack you can even see.
Velocity checks and rate limits are the same counter asking different questions
A rate limit protects capacity. It asks whether this caller can have another request right now, and the honest answer is a 429 with a Retry-After header. The counter is keyed on whatever identifies a caller cheaply, usually the IP, and the threshold comes from what your servers can absorb.
A velocity check protects money. It asks whether this many of these, from this identity, in this window, is something a real customer does. The counter looks identical. The answer is a risk decision: let it through, let it through and look at it later, or decline it.
The two questions want different keys and different windows. Nobody makes five hundred checkout attempts because they are enthusiastic, so a checkout counter keyed on a card can fire at five. A rate limit on the same endpoint keyed on IP has to tolerate a whole office behind one address, so it fires at hundreds and catches nothing that matters. A rate limiter renamed "fraud prevention" is still a rate limiter.
Stripe's card testing guidance says as much: filters based on a single heuristic such as IP address are usually not sufficient. Read that as an engineering statement about the key.
The key is the decision
Every counter is count(events where key = K) over window W. Pick K badly and no threshold saves you. Here is each key you can reasonably count on, and how it fails.
IP
The cheap key, and it fails in both directions at once.
It over-counts because addresses are shared. Cloudflare's analysis of carrier-grade NAT found that a single IPv4 address may represent hundreds or even thousands of users, and that those addresses get rate limited three times as often as the rest. Corporate NAT, campus Wi-Fi, and mobile carriers all look the same to your counter: one address, many humans.
It under-counts because addresses are rented. Residential proxy pools sell exit addresses by the gigabyte, and the OWASP credential stuffing cheat sheet notes that attack toolkits ship with proxy networks built in, specifically so that per-IP volume stays low. The attacker who cares has one request per address. Your counter never reaches two.
So IP can raise suspicion and it can never carry a block on its own. A per-IP counter that fires should put the event in review, or add a CAPTCHA, and stop there. Make sure the address you count is the real one, too. If you read x-forwarded-for from the wrong end of the chain, the attacker sets the key to whatever they like.
Trivially rotated. Plus-addressing, dot-tricks on Gmail, and disposable domains give one person an unbounded supply. Counting per normalized email (strip plus tags and dots, lowercase) catches the lazy. Counting per domain catches the disposable-provider crowd and also flags every large company whose staff sign up in the same week. Email is a linking hint for the identity graph, not a key you would block on.
Card fingerprint
Strong. The processor hands you a stable fingerprint for a card number, and a real cardholder uses the same card across one or two accounts, not forty. The reverse direction is the useful one: many distinct cards behind one email is the signature of card testing. Stripe's Radar rules expose exactly these counters, with names such as card_count_for_email_hourly, and cap them at 25 because nothing legitimate needs to count higher.
The weakness is coverage. A card fingerprint exists only once a card has been attached, which is the moment the attacker has already extracted what they wanted from a SetupIntent. Card keys stop the tenth probe. They cannot see the first.
Device
The stickiest cheap key. A browser fingerprint survives a cleared cookie jar, a new email, and a proxy switch, because it is derived from the machine rather than from anything the user types. OWASP is right that everything in it is client-supplied and can be spoofed, but forging a fresh, consistent device per attempt costs the attacker real effort where rotating an IP costs nothing.
That cost asymmetry is why device is the first key that can carry a block at ordinary amounts. Five distinct cards from one device in ten minutes is not a customer having a bad day. Ten signups from one device in an hour is not a family. The population sharing a fingerprint is roughly one, so review can become block without collateral damage.
Account
Per-account limits are easy to write and easy to walk around. "Max three cards per account per day" stops nobody who creates a fourth account. Account keys still matter for login, where the question is whether this login is unusual for this account, and for catching a single compromised account being drained. For abuse that spans accounts, the account key sees one slice of the person.
Cluster
The person behind the ten accounts is the thing you want to count. A cluster is the set of accounts linked by shared hard keys: same card fingerprint, same device, same phone, same normalized email root. Count trials per cluster and the fourth free trial from a "new" account shows up as the fourth. Convict one account for chargebacks and the whole cluster inherits it.
This is the counter I could not buy off the shelf, so it is the one I built. Portreeve keeps velocity counters per IP, per device, per card, and per cluster, with different authority for each: per-IP counters can only ever return review, device- and cluster-keyed counters can return block, and the reason codes on the response name which counter tripped. The browser snippet is what makes the device key available; without it those shapes top out at review.
If you build this yourself, the linking table is the hard part, not the counters. Every hashed identity key points at a cluster id, a new event joins the cluster of any key it matches, and two clusters merge when an event bridges them. Hash keys from day one. You do not want raw emails and card fingerprints sitting in a join table.
Windows are separate signals, not one dial
A ten-minute window and a thirty-day window on the same key are different detectors. Do not pick one.
Ten minutes catches scripts. Card testers run authorization ladders because a SetupIntent validates a card without moving money and does not typically show up on the cardholder's statement, and a script does that at machine speed.
An hour catches a human with a list. Someone working through fifty stolen numbers by hand, pausing between them, never trips a ten-minute counter and trips an hourly one at the fifth card.
Twenty-four hours catches the patient version. OWASP's phrasing for credential stuffing is that short bursts and long periods should both be considered, because the attacker who knows you have a burst limit spreads the same volume out.
Thirty days catches trial cycling. Nobody creates a trial, lets it lapse, and creates another from the same device within a month by accident. This window is nearly useless on IP, weak on email, and decisive on device and cluster.
Ladders sit on top of windows. A ladder is two thresholds on the same counter: review at n, block at m. The gap between them is where you learn. Reviews that turn out fine tell you n is too low; blocks that generate support tickets tell you m is. Start wide and close the gap from the data.
Distinct counts beat event counts
An event count answers "how many times." A distinct count answers "how many different." For fraud the second is almost always the sharper question.
Five checkout attempts from one device could be a flaky card and an annoyed customer. Five distinct card fingerprints from one device is card testing, full stop. Twenty logins on one account in an hour might be a broken mobile client retrying. Twenty distinct accounts attempted from one device in an hour is credential stuffing. The event count needs a threshold high enough to tolerate retries, which makes it slow. The distinct count can fire low.
The pairs worth keeping:
- Distinct cards per device, per email. Card testing and cash-out.
- Distinct accounts per device, per card. Trial abuse and multi-accounting.
- Distinct devices per account, checked against the account's own baseline. Account takeover.
- Distinct emails per card. A stolen card spread across fresh signups.
Radar already keeps several of these inside Stripe. The ones it cannot keep are the device-keyed ones and anything at signup or login, because Radar only sees the payment.
A sliding window in Redis, and the mistakes around it
A fixed window (INCR on a key named for the bucket) is cheapest and lets twice the limit through at the boundary. A sliding log (sorted set of timestamps) is exact and costs memory linear in the count. A sliding counter (two fixed buckets, weighted) is the usual compromise. The Redis team's tutorial walks through all five common algorithms; the point people skip is that the read-decide-write sequence must be atomic, which in practice means a Lua script rather than MULTI.
For fraud counters the sorted set has an extra property that makes it the right choice. If the member is the thing you want to count distinctly, ZADD updates the score instead of adding a duplicate, and ZCARD becomes a distinct count for free.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
// Distinct `member`s seen under `key` in the last `windowMs`.
const DISTINCT_IN_WINDOW = `
local key, member, now, window = KEYS[1], ARGV[1], tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return redis.call('ZCARD', key)
`;
export async function distinctInWindow(key: string, member: string, windowMs: number) {
return Number(await redis.eval(DISTINCT_IN_WINDOW, 1, key, member, Date.now(), windowMs));
}
// cards seen on this device in the last 10 minutes
const n = await distinctInWindow(`v:cards:device:${deviceId}`, cardFingerprint, 10 * 60_000);
Call it with a random member and it degrades into a plain event count.
The mistakes, in the order I have seen them:
Counting the denied attempt. Stripe's counters exclude the payment currently being evaluated, so a blocked attempt does not push the next legitimate one over the line. The script above records before it counts, which is right for a detector: you want the attacker's blocked probes to accumulate. If the same counter drives a customer-facing limit, count first and record only on allow.
Keying on the wrong address. Behind a proxy, the first x-forwarded-for entry is attacker-controlled. Take the entry your own infrastructure appended and nothing else.
Forgetting expiry. Every key needs a TTL at least as long as its window, or the set grows until Redis evicts it and your counters silently reset.
Building the counters and not the queue. A counter that fires into a log nobody reads gets tuned by support tickets. Route review-level hits somewhere a human sees them the same day.
Which counter catches which attack
The three attacks that hit small software companies map onto specific counters, and the map is the argument for keying on device and cluster.
Card testing is distinct cards per device in ten minutes, distinct cards per email in an hour, and declined authorizations per device in an hour. The IP counter for this goes quiet the moment the attacker rotates. The practitioner fix reported on Hacker News by tinyprojects was "IP-based bans on creating checkout links" plus notifications for "many failed attempts using diff cards." The second half of that sentence is the one doing the work.
Trial abuse is distinct accounts per device in thirty days, trials per cluster in thirty days, and signups per normalized email root in a day. No ten-minute window catches it, because the abuser is not in a hurry. The person with fourteen trials is only visible on the key that survives a new email and a cleared browser.
Credential stuffing is distinct accounts attempted per device in an hour, failed logins per account against its own baseline, and the global failure ratio over ten minutes. That last one is a velocity check with no key at all. An attacker spread across a proxy pool never trips a per-key counter, but the ratio of failed to successful logins across your whole login endpoint jumps. It identifies nobody, so it cannot block anybody. It can turn on step-up authentication site-wide for an hour.
Thresholds you can defend
Thresholds are the part people ask about first and the part that matters least, so here are starting points. They are opinions from running these counters, not statistics. Set them, watch the review queue for two weeks, and move them.
| Counter | Window | Review at | Block at |
|---|---|---|---|
| Distinct cards per device | 10 min | 3 | 5 |
| Distinct cards per email | 1 h | 3 | 6 |
| Declined auths per device | 1 h | 3 | 8 |
| Signups per device | 1 h | 3 | 6 |
| Trials per cluster | 30 d | 2 | 4 |
| Distinct accounts attempted per device | 1 h | 5 | 15 |
| Signups per IP | 1 h | 10 | never |
| Checkouts per IP | 10 min | 8 | never |
Every block threshold sits on a device or cluster key, and the IP rows have no block column at all. That is the whole argument in a table.
Tune from the review queue, not from the block count. A wrong block produces a support ticket you may never see. A wrong review produces a queue item you resolved in four seconds, and that resolution is the signal that moves the line. Review is how you get to be aggressive without being wrong in front of a customer. The mechanics of acting on each verdict, including revoking after a review comes back as abuse, are in the handling verdicts guide.
One more, since the advice is everywhere. In that same Hacker News thread, imtu80 suggested "5 per within 1 minutes then block them for 15 minutes" on the IP. Against a script on one address it works for exactly as long as it takes to add a proxy list, and against a shared address it blocks the office. Keep the counter. Change the verdict to review. Put the block on the device.
If you would rather not run the cluster table yourself, Portreeve does the counting: one call at signup, trial, checkout, or login, and the free tier covers 1,000 screened events a month with no card required. Start with a free account and send your first verdict from test mode.