How to stop free-tier abuse on an AI app (without killing signups)
On most SaaS products a fake free account costs you a row in a database. On an AI app it costs you tokens. Every new account that burns its free credits is a bill, and the people who farm free tiers know it: new email, n
On most SaaS products a fake free account costs you a row in a database. On an AI app it costs you tokens. Every new account that burns its free credits is a bill, and the people who farm free tiers know it: new email, new browser profile, sometimes a VPN, same workload as yesterday.
The usual fixes each have a hole. Email verification stops nobody with a disposable inbox. A card on the free tier stops the abuse and most of your signups with it. IP rate limits lock out a whole office behind one NAT.
What works is layers, each cheap on its own, ordered so the first ones protect your bill even before you catch anyone. This is the setup, in the order we'd build it.
1. Cap the blast radius first
Before you try to detect anything, make sure the worst case is a number you chose.
- A global daily budget for all free usage. When it's spent, free requests queue or wait until tomorrow. One bad night can't become a surprise invoice.
- A per-account daily cap that's lower than you think you need.
- Check both on the server, before the model call, not after.
// Before calling the model for a free-tier user
async function canSpendFree(userId: string, estTokens: number) {
const today = new Date().toISOString().slice(0, 10);
const [global, user] = await Promise.all([
redis.incrby(`free:global:${today}`, estTokens),
redis.incrby(`free:user:${userId}:${today}`, estTokens),
]);
await redis.expire(`free:global:${today}`, 172800);
await redis.expire(`free:user:${userId}:${today}`, 172800);
return global <= GLOBAL_FREE_DAILY && user <= USER_FREE_DAILY;
}
This doesn't stop farming. It makes farming boring and bounded, which is most of the battle.
2. Make each account worth less
Farming is economics. Lower the payout per account and most farmers stop.
- Drip credits instead of one lump. "50 a day for a week" is far less worth farming than "350 now". An honest user barely notices.
- Start new accounts on a cheaper model. The good model unlocks after email verification and a bit of normal use. The farmer's cost per account goes up; yours goes down.
- Unlock more with trust: account age, normal usage, a verified email, or a card, but ask for the card only when an account looks risky (step 5).
3. Clean up the email layer
Not because it stops determined people, but because it's an afternoon of work and removes the laziest farming.
const DISPOSABLE = new Set(disposableDomains); // e.g. the open-source "disposable-email-domains" list
export function normalizeEmail(raw: string) {
const [local, domain] = raw.trim().toLowerCase().split('@');
const d = domain === 'googlemail.com' ? 'gmail.com' : domain;
let l = local.split('+')[0]; // drop +tags
if (d === 'gmail.com') l = l.replace(/\./g, ''); // Gmail ignores dots
return { email: `${l}@${d}`, disposable: DISPOSABLE.has(d) };
}
Store the normalized email with a unique index, and keep a hash of it when an account is deleted, so "delete and sign up again" doesn't reset the free tier.
4. Group accounts instead of judging each one
A single signal proves nothing. A VPN is common for privacy-minded users; a shared IP is every office and mobile carrier. What gives farmers away is that their accounts look alike:
- Network type, not just IP. An office NAT or a mobile carrier is a residential or mobile ISP. Ten accounts in an hour from VPN exits and cloud servers are not. This distinction is what keeps your false positives down.
- Network changes inside one session: the IP, ASN or country switching mid-signup is a strong sign of a rotating proxy.
- Timing: a new signup minutes after the previous account ran out of credits.
- Workload: the same first prompts, files or project shape as an older account.
Log these for every signup from day one. You can't cluster history you didn't keep.
5. Add friction only where signals stack up
Now use the signals, gently:
| Signals | Response |
|---|---|
| Nothing unusual | Straight in, normal free tier |
| One weak signal (a VPN, say) | Straight in, maybe the drip starts smaller |
| Several signals, or a known-bad network | A Turnstile check, email or phone verification before credits |
| Clearly the same person as a capped account | No free credits until they verify with a card |
The honest majority never sees any of this. The farmer sees it on every account.
Where FindIP Shield fits
Disclosure: we make FindIP Shield, a free tool that does the network part of steps 4 and 5. It scores each visit and form submit from 0 to 100 with the reasons (VPN, proxy, Tor, hosting or datacenter network, known malicious IP, IP changes during the session), and can add a short wait, a Turnstile check or a stop message for risky submits only, off until you switch it on. It scores the network, not the device: there's no device fingerprinting, so on its own it won't recognize the same laptop on a fresh profile. It never reads form values.
The browser side is one script tag:
<script
src="https://cdn.findip.net/shield/v1.js"
data-site-key="pub_xxxxxxxxx"
data-auto-track="true"
data-privacy-mode="balanced">
</script>
Send the Shield session ID with your signup request (FindIP.getSession().sessionId), then let your server make the decision, because anything decided in the browser can be bypassed:
async function shieldVerdict(sessionId: string) {
const res = await fetch('https://shield.findip.net/v1/shield/sessions/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SHIELD_SECRET_KEY}`, // sec_..., server only
'Content-Type': 'application/json',
},
body: JSON.stringify({ session_id: sessionId, event: 'signup_attempt' }),
});
const v = await res.json();
// Unknown is not safe: no session, or no IP intelligence for it.
if (!v.verified || v.risk_status === 'unknown') return 'monitor';
if (v.recommendation === 'block') return 'require_card';
if (v.recommendation === 'challenge') return v.challenge_passed ? 'allow' : 'require_verification';
return 'allow';
}
Combine that verdict with your own signals from steps 3 and 4, and feed it into the table in step 5. Shield is free up to 10,000 events per site per day; there's also a WordPress plugin, a Shopify app, a GTM template and a no-code Cloudflare integration if your signup page isn't hand-written.
The short version
- Cap global and per-account free spend on the server.
- Drip credits and start new accounts on a cheaper model.
- Normalize emails, block disposable domains, remember deleted accounts.
- Cluster accounts by network type, timing and workload.
- Add friction only where signals stack up, and never block VPN users outright.
You won't stop every determined person. You don't need to: you need farming to cost them more than it saves, while honest users still get in with one click.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.