A practical playbook for securing B2B SaaS signups
Last month, a B2B SaaS client came to us with a familiar problem: signups looked fine on the surface, but free credits were draining fast. Digging into their logs revealed a coordinated attack — dozens of registrations from the same /24 subnets, rotating disposable emails, and a single attacker reusing infrastructure across multiple fake accounts.
This article walks through the defense stack we recommended. Names and identifiers are anonymized, but the flow is production-ready.
What made the attack easy
Three things worked in the attacker's favor:
- All free credits granted at signup — no friction, no verification, instant value.
- No device binding — the same browser fingerprint could register again and again.
- IP checks skipped or misconfigured — behind Cloudflare's orange cloud, the app was reading proxy IPs instead of the visitor's real address.
Fixing signup security is less about one silver bullet and more about layering signals that compound.
Priority 1: Read the real IP address
If your app sits behind Cloudflare with the proxy enabled (orange cloud), req.ip or X-Forwarded-For alone is not enough. Cloudflare sends the visitor's real IP in the CF-Connecting-IP header.
function getClientIp(request: Request): string {
const cfIp = request.headers.get("cf-connecting-ip");
if (cfIp) return cfIp;
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return "0.0.0.0";
}
Every downstream check — rate limits, IP reputation, blocking — depends on getting this right first.
Priority 2: Score the IP at registration
Run the Veille IP Reputation API asynchronously during signup. Do not block the HTTP response on it if you can avoid it; queue the check and act on the result within seconds.
const API_KEY = process.env.VEILLE_API_KEY!;
const BASE_URL = "https://api.veille.io/v1";
async function scoreIp(ip: string) {
const response = await fetch(
`${BASE_URL}/intelligence/ip?query=${encodeURIComponent(ip)}`,
{ headers: { "x-api-key": API_KEY } }
);
return response.json();
}
The threat_score aggregates detected flags:
| Score | Meaning |
|---|---|
| 0 | Clean residential or business IP |
| 1–25 | Minor flags (datacenter, VPN) |
| 25–50 | Moderate risk (multiple flags) |
| 50+ | High risk (Tor, known abuser, proxy) — block or require extra verification |
In our client's logs, attacker IPs clustered in /24 ranges with scores above 50. Blocking the subnet after repeated abuse, not just the single IP, cut volume quickly.
Priority 3: Add FingerprintJS
Install FingerprintJS on your signup and login pages. Store the visitorId with every registration attempt.
import FingerprintJS from "@fingerprintjs/fingerprintjs";
async function getFingerprint(): Promise<string> {
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId;
}
Then enforce rate limits on both IP and fingerprint:
- After 5 failed signup or login attempts from the same fingerprint or IP → block for 24 hours.
- Log both values on every attempt so you can pivot in your back office.
Priority 4: Validate the email asynchronously
Disposable emails will not stop a determined attacker, but they filter a large share of low-effort abuse. Call the Veille Email Validation API in parallel with the IP check:
async function validateEmail(email: string) {
const response = await fetch(
`${BASE_URL}/intelligence/email?query=${encodeURIComponent(email)}`,
{ headers: { "x-api-key": API_KEY } }
);
return response.json();
}
// During signup handler:
const [ipResult, emailResult] = await Promise.all([
scoreIp(clientIp),
validateEmail(email),
]);
if (emailResult.disposable || emailResult.risk_score > 70) {
await blockIpTemporarily(clientIp, "disposable_email");
return { error: "Registration blocked" };
}
if (ipResult.threat_score >= 50) {
await blockIpTemporarily(clientIp, "high_threat_ip");
return { error: "Registration blocked" };
}
Run these checks after accepting the form but before granting credits. If either signal fails, block the IP for a configurable window and return a generic error.
Priority 5: Bind fingerprint to email verification
Creating an account is not the same as proving you control the inbox. Require email verification before any credits are usable.
The critical step most teams skip: the fingerprint at signup must match the fingerprint when the user clicks the verification link.
// At signup — store pending verification
await db.pendingVerifications.create({
email,
fingerprintSignup: fingerprint,
ipSignup: clientIp,
token: verificationToken,
expiresAt: addHours(new Date(), 24),
});
// At verification — compare fingerprint
const pending = await db.pendingVerifications.findByToken(token);
if (pending.fingerprintSignup !== currentFingerprint) {
await flagAccount(pending.email, "fingerprint_mismatch");
return { error: "Verification failed" };
}
await activateAccount(pending.email);
A mismatch does not always mean fraud — users switch devices — but repeated mismatches from high-risk IPs are a strong signal.
Priority 6: Stop giving away all credits at signup
Free credits are an attacker's ROI. Spread value across actions that bots struggle to automate:
| Milestone | Credits | Why |
|---|---|---|
| Account created | 0 | No immediate value to extract |
| Email verified | +10 | Proves inbox control |
| Onboarding completed | +25 | Requires real product interaction |
| Social share confirmed | +15 | Adds friction and audit trail |
Example onboarding flags in your back office:
interface UserCredits {
emailVerified: boolean; // +10 credits
onboardingCompleted: boolean; // +25 credits
sharedOnSocial: boolean; // +15 credits
}
function getAvailableCredits(user: UserCredits): number {
let credits = 0;
if (user.emailVerified) credits += 10;
if (user.onboardingCompleted) credits += 25;
if (user.sharedOnSocial) credits += 15;
return credits;
}
Bots optimize for the fastest path to credits. An onboarding step with real API calls, configuration choices, or a short tutorial breaks scripted flows.
Putting it together: signup flow
User submits signup form
│
▼
Read CF-Connecting-IP
│
▼
Check fingerprint + IP rate limits ──► blocked? → 429, log attempt
│
▼
Create account (0 credits)
│
├──► async: IP reputation check
└──► async: email validation check
│
▼
threat detected? → block IP, flag account
│
▼
Send verification email (store signup fingerprint)
│
▼
User clicks link → fingerprint match? → grant +10 credits
│
▼
User completes onboarding → grant +25 credits
│
▼
User shares on social → grant +15 credits
Back-office booleans worth adding
Give your team toggles to react without deploying code:
block_disposable_emails— reject disposable inboxes at signupblock_high_threat_ips— auto-block IPs withthreat_score >= 50require_fingerprint_match— enforce fingerprint binding on email verificationrate_limit_by_fingerprint— enable fingerprint-based throttlinggrant_credits_on_signup— keep atfalseduring an active attack
These flags let you tighten defenses during an incident and relax them once traffic normalizes.
Last resort: slow down, do not announce
If an IP is clearly abusive but you want to avoid tipping off the attacker, some teams introduce artificial friction — delayed responses, CAPTCHA on the second attempt, or a fake SMS code that never arrives. This is controversial and should be a last resort, clearly logged internally, and never shown to legitimate users.
The goal is to burn the attacker's time, not yours.
Results
After implementing this stack — real IP extraction, Veille IP and email checks, FingerprintJS rate limits, fingerprint-bound verification, and staged credits — our client's abusive signup volume dropped sharply within 48 hours. Legitimate users completed onboarding without noticing the extra checks.