Your signup count went up. Your activation count did not.
Somewhere in that gap are fake accounts, and the phrase names three different populations that leave three different traces in your tables. Most writing on fake account detection assumes you have a bot problem. In a small software product you usually do not. You have one person, on their eleventh identity, arriving on a Tuesday afternoon and looking exactly like a first-time customer.
Which of the three you have is a question your own database answers tonight.
What makes an account fake
Realism is the wrong axis. The Gmail address receives mail. The name is a name. The card authorizes. Nothing about the account is forged.
Define it by cost instead. An account is fake when it exists to obtain something you meant to give once, or when the identity it presents is not the creator's. Both definitions are about the second account, not the first. A single person with a working email and a working card is a customer; the same person on their fourth trial is a cost you priced once and are paying four times.
That framing kills a query you were probably about to run. An account that never activates is not evidence of anything. Most people who sign up for most products do nothing, and if you go hunting by looking at the dead ones you will find your entire funnel. Non-activation is a filter you apply after you have a link, never the link itself.
Three populations of fake user accounts that behave nothing alike
They separate on different axes, which is why one detection rule never covers all three.
Bulk automation is a script against your signup endpoint. It separates on velocity inside a short window: hundreds of registrations in minutes, one or two autonomous system numbers behind the addresses, client fingerprints that repeat exactly because they come from the same headless build, and usernames with the shape of a generator. This population is loud. An hourly histogram of signups will show it to you in thirty seconds.
Single-operator multi-accounting is a human being who wants a twelfth trial. It separates on identity-key overlap across days or weeks, which makes it invisible to anything looking at one request. Stripe's analysis of first-party fraud cites 451 Research finding that one in five consumers admit to using different email addresses or contact information to access promotions and discounts multiple times, rising to 29% of Gen Z. That is not a criminal population. That is your addressable market with a spreadsheet.
On Hacker News, caydenm described the shape precisely: a user who signed up 8 week in a row using different emails, names, IPs and cards. Every field rotated, spread across two months. No velocity rule sees that. It is one signup a week.
Stolen-identity accounts are the rarest and the most expensive per event. They separate on mismatch between the account holder and the instrument presented: cardholder name against the name on the account, card country against IP country against billing country, an account created minutes before its first high-value purchase. The terminal signal arrives weeks or months later as a dispute with a fraud reason code, which is why you find this population in your ledger rather than your logs.
| Population | Separates on | The query that finds it |
|---|---|---|
| Bulk automation | Velocity in minutes | Signups per hour grouped by ASN |
| Single-operator | Key overlap over weeks | Distinct accounts per card and device fingerprint |
| Stolen identity | Holder/instrument mismatch | Cardholder name and card country against account fields |
My read from payments work is that in a self-serve software product the middle row dominates by count and the third dominates by dollars, but that ratio is yours to measure, not to inherit from a blog post.
The email address proves nothing
Email verification proves an inbox exists. It says nothing about whether the person behind it is new to you, and for the largest consumer mail provider it is defeated without any tooling.
Google documents both mechanisms. On dots: if someone accidentally adds dots to your address when emailing you, you'll still get that email. On plus-addressing: just add a plus sign (+) and any word before the @ sign in your current address, and every variant lands in the same inbox. An eleven-character Gmail username yields more dotted variants than anyone needs, and the plus tag is unbounded.
One detail worth getting right if you normalize: the dot rule is consumer Gmail only. Google's page says that if you use Gmail through work or school, dots do change your address, so [email protected] and [email protected] are different people. Strip dots for gmail.com and googlemail.com. Strip plus tags everywhere, because subaddressing is a general convention. Never strip dots for anything else.
Disposable domain blocklists have a different problem, which is that they are a subscription to someone else's diligence. The most widely used open list adds new domains on most days, including one on the day this post went out. Run it anyway, but understand what you bought: a lagging indicator against an operator who can register a fresh domain for the price of lunch and be ahead of the list again by Friday.
So do not reject on the email. Normalize it, hash it, and store the hash as a link key, so twelve variants of one legitimate Gmail address resolve to one node in your graph.
What CAPTCHA and a card requirement buy you
CAPTCHA prices the attack rather than stopping it, and the price is public. 2Captcha lists reCAPTCHA v2 at $1.00 to $2.99 per 1,000 solves and Cloudflare Turnstile at $1.45 per 1,000. For a scripted attack that is a line item.
The premise has also inverted. Searles et al. put 1,400 participants through 14,000 CAPTCHAs for USENIX Security 2023 and found that bots outperform humans on both solving time and accuracy across the types they tested, with human accuracy between 50 and 84 percent. The test discriminates, just not in the direction it was designed to.
Against the single-operator population it is worse, because the operator is a human. A CAPTCHA costs them a few seconds on each of eleven signups. You are charging a real person a small tax to do the thing you did not want them to do, eleven times, and they pay it eleven times.
CAPTCHA is still right in one place: raising the floor cost on scripted bulk registration, where a few dollars per thousand against a few dollars of value per account occasionally bites. If you want it, run something invisible.
"Require a card" is the advice I would push back on hardest. It suppresses real signups at the exact moment someone is curious enough to try, and it does not stop the operator, who mints a fresh number per account. What a card gives you is not a gate. It is a fingerprint, the most expensive key in the graph, and you get it the moment anyone pays for anything without demanding it up front from everyone who does not.
The keys worth linking on
Rank identity keys by what it costs the attacker to change them, and spend your effort at the top.
Card fingerprint costs the most. Stripe's Card object documents fingerprint as a value that uniquely identifies this particular card number, so two customers presenting the same number produce the same string in your account. Two caveats will bite you. The same page notes that for Apple Pay and Google Pay the tokenized number might be provided instead of the underlying card number, so one physical card can produce two values. And a virtual card issuer hands a determined operator a new number, and therefore a new fingerprint, for free. It is still the strongest key you will get.
Device fingerprint costs time. A new browser profile, a clean cookie jar, and a resized window are cheap; a genuinely distinct device signature that survives a session is not, which is why antidetect browsers exist as a paid product. The device fingerprinting guide covers collecting a signed short-lived token in the browser.
Normalized email hash costs nothing to change and nothing to compute, which makes it the right first key: it will not catch a careful operator, and it will collapse the twelve-variant Gmail case on the day you ship it.
IP belongs in a counter and never in a link. RFC 6598 reserves 100.64.0.0/10 as Shared Address Space precisely so that carriers can put many subscribers behind one public address. Corporate egress does the same thing on purpose. Counting signups per IP per hour is a good bot signal. Linking two accounts because they shared an IP will eventually merge a university with an office park.
Then the part that matters more than any individual key: act on the cluster, not the account. Account eleven is not suspicious on its own; account eleven is suspicious because it shares a card fingerprint with accounts one through ten, and when one of those is confirmed abusive the other ten should inherit that judgment without you running anything by hand.
That inheritance is the part that takes real work to build, and it is why Portreeve exists. One call at signup, trial_start, trial_convert, checkout_attempt, or login returns allow, review, or block with reason codes in under 100 ms, against a graph that links accounts across hashed email, device fingerprint, card fingerprint, phone, and payer wallet. Confirmed abuse on one account marks the whole linked cluster; IP stays a soft signal that never links on its own. review never blocks anyone: the flow proceeds, the event lands in a queue, and a later deny reaches your server on a signed webhook so you can revoke. The event payload reference lists what each event type takes. It is in open beta.
Reading your own database tonight
Four queries, in the order that tells you the most per minute of work. Postgres syntax; adjust the table names.
Start with velocity, because if you have a bot problem it is obvious and it changes what you do next.
select date_trunc('hour', created_at) as hour,
signup_asn,
count(*) as signups,
count(distinct signup_ip) as ips
from users
where created_at > now() - interval '30 days'
group by 1, 2
having count(*) > 20
order by signups desc;
If you do not store the ASN, group by the /24 of the signup IP and look up the noisy ones by hand. You are looking for an hour with a hundred signups from one datacenter network. If the top row is eleven signups from a consumer ISP, you do not have a bot problem, and everything below matters more.
Next, distinct accounts per card fingerprint. This is the highest-signal query most products have never run.
select card_fingerprint,
count(distinct user_id) as accounts,
min(created_at) as first_seen,
max(created_at) as last_seen
from payment_methods
where card_fingerprint is not null
group by 1
having count(distinct user_id) > 1
order by accounts desc;
Then normalized-email collisions, which cost nothing and usually surprise people.
with e as (
select id, created_at, lower(email) as email from users
)
select case
when split_part(email, '@', 2) in ('gmail.com', 'googlemail.com')
then replace(split_part(split_part(email, '@', 1), '+', 1), '.', '')
|| '@gmail.com'
else email
end as normalized,
count(*) as accounts,
array_agg(email order by created_at) as variants
from e
group by 1
having count(*) > 1
order by accounts desc;
Fourth, use time-to-first-value as a filter on the clusters the first three produced, never as a detector on its own. For each cluster, compute the median seconds from users.created_at to each account's first meaningful action, and the share that never reached it. A family sharing a laptop has a normal spread of activation times. A cluster where every account did the expensive thing in the first four minutes and never came back is not a family.
Run all four before you change any code. Two will come back empty, and knowing which two is the whole point.
What to do once you have a cluster
The instinct after the card-fingerprint query returns a row with nine accounts is to ban all nine. Resist it for a week.
Look at your biggest clusters by hand first. You will find the operator you were looking for. You will also find your own QA accounts, an agency running five client workspaces from one laptop, a household, and a coworking space. That is why the correct default for an ambiguous cluster is a review lane rather than a hard block: a queue costs you a few minutes of attention, and a wrong block costs you a customer who will not write in to complain about it.
Blocking is also not always the highest-value action. The end of caydenm's account of that eight-week multi-accounter is the interesting part: after nudging was turned on, on trying to sign up for their 9th trial they immediately switched back to their original account and converted at full price. Someone taking eight trials is demonstrating demand in the most expensive way available to them. A cluster is a signal about willingness to pay as often as it is a signal about abuse.
Reserve hard blocks for the shape where you are confident: a new account sharing a strong key with a cluster that already contains a confirmed chargeback or a confirmed abuse report. Everything softer, including everything resting on IP or raw velocity, stays at review.
Last, keep the links honest by feeding outcomes back. When you confirm a cluster was one operator, record it against the keys; when you review one and find a coworking space, record that too. Reason codes are what make the decision auditable six months later when someone asks why an account was flagged, and the reference on reason codes is worth reading before you design your own. A graph nobody corrects drifts toward blocking families with one laptop.
The queries above are a night's work and they will tell you which of the three populations you have. If you would rather have the linking, the counters, and the verdict without building the graph yourself, Portreeve's free tier covers 1,000 screened events a month with no card. Create an account and put one call in front of your signup form.