Back to blog
Security

A practical playbook for securing B2B SaaS signups

Josselin Liebe
Josselin Liebe

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:

  1. All free credits granted at signup — no friction, no verification, instant value.
  2. No device binding — the same browser fingerprint could register again and again.
  3. 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 signup
  • block_high_threat_ips — auto-block IPs with threat_score >= 50
  • require_fingerprint_match — enforce fingerprint binding on email verification
  • rate_limit_by_fingerprint — enable fingerprint-based throttling
  • grant_credits_on_signup — keep at false during 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.

Related articles