spam-prevention

When the user needs to prevent spam signups, bot accounts, fake registrations, or abuse of signup/trial flows. Also use when mentioning "spam accounts," "fake signups," "bot registrations," "disposable emails," "signup abuse," or "trial fraud." For broader security concerns, see saas-security.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "spam-prevention" with this command: npx skills add hwatkins/my-skills/hwatkins-my-skills-spam-prevention

Spam & Bot Prevention for SaaS

Expert guidance for preventing spam signups, bot accounts, and abuse of registration and trial flows in SaaS applications.

Core Principles

  • Layer defenses — no single technique stops all spam; combine multiple signals
  • Minimize friction for real users — invisible protections first, visible challenges only when needed
  • Fail closed on high-risk signals — block or queue for review rather than letting spam through
  • Monitor and adapt — spammers evolve; your defenses must too

Defense Layers (Priority Order)

Layer 1: Invisible Protections (Zero Friction)

These run silently — real users never notice them.

Honeypot Fields

Add hidden form fields that real users won't fill in but bots will.

<!-- Hidden from real users via CSS -->
<div style="position: absolute; left: -9999px;" aria-hidden="true">
  <label for="website">Website</label>
  <input type="text" name="website" id="website" tabindex="-1" autocomplete="off" />
</div>
# Server-side: reject if honeypot field has a value
def create(conn, %{"user" => user_params}) do
  if user_params["website"] && user_params["website"] != "" do
    # Bot detected — return fake success (don't reveal detection)
    conn
    |> put_flash(:info, "Check your email to confirm your account.")
    |> redirect(to: ~p"/")
  else
    # Real signup flow
    Accounts.register_user(user_params)
  end
end

Time-Based Detection

Bots submit forms instantly. Real users take time.

# Add a hidden timestamp field
def new(conn, _params) do
  token = Phoenix.Token.sign(conn, "form_time", System.system_time(:second))
  render(conn, :new, form_token: token)
end

def create(conn, %{"form_token" => token} = params) do
  case Phoenix.Token.verify(conn, "form_time", token, max_age: 3600) do
    {:ok, timestamp} ->
      elapsed = System.system_time(:second) - timestamp
      if elapsed < 3 do
        # Submitted in under 3 seconds — likely bot
        fake_success_response(conn)
      else
        real_signup(conn, params)
      end
    {:error, _} ->
      fake_success_response(conn)
  end
end

JavaScript Challenge

Many bots don't execute JavaScript. Require a JS-generated token.

// On page load, generate a token after a delay
setTimeout(() => {
  document.getElementById('js_token').value = btoa(Date.now().toString());
}, 1000);
# Server: reject if js_token is missing or invalid
if is_nil(params["js_token"]) or params["js_token"] == "" do
  fake_success_response(conn)
end

Layer 2: Email Verification

Disposable Email Blocking

Block known throwaway email domains.

# Use a library like `disposable_email_domains` or maintain your own list
@disposable_domains File.read!("priv/disposable_domains.txt")
  |> String.split("\n", trim: true)
  |> MapSet.new()

def validate_email_domain(changeset) do
  validate_change(changeset, :email, fn :email, email ->
    domain = email |> String.split("@") |> List.last() |> String.downcase()
    if MapSet.member?(@disposable_domains, domain) do
      [email: "please use a permanent email address"]
    else
      []
    end
  end)
end

Sources for disposable domain lists:

  • disposable-email-domains (community-maintained, 100k+ domains)
  • Services: Kickbox, ZeroBounce, NeverBounce (API-based, real-time)

Email Confirmation Flow

Require email verification before activating accounts.

def register_user(attrs) do
  %User{}
  |> User.registration_changeset(attrs)
  |> Ecto.Changeset.put_change(:confirmed_at, nil)
  |> Repo.insert()
  |> case do
    {:ok, user} ->
      deliver_confirmation_email(user)
      {:ok, user}
    error -> error
  end
end

# Don't allow login until confirmed
def get_user_by_email_and_password(email, password) do
  user = Repo.get_by(User, email: email)
  if user && user.confirmed_at && User.valid_password?(user, password) do
    user
  end
end

MX Record Validation

Verify the email domain actually has mail servers.

def valid_mx_record?(email) do
  domain = email |> String.split("@") |> List.last()
  case :inet_res.lookup(to_charlist(domain), :in, :mx) do
    [] -> false
    _records -> true
  end
end

Layer 3: Rate Limiting

IP-Based Rate Limiting

# Using Hammer (Elixir rate limiter)
def create(conn, params) do
  ip = conn.remote_ip |> :inet.ntoa() |> to_string()

  case Hammer.check_rate("signup:#{ip}", 60_000, 5) do
    {:allow, _count} ->
      real_signup(conn, params)
    {:deny, _limit} ->
      conn
      |> put_status(429)
      |> put_flash(:error, "Too many signup attempts. Please try again later.")
      |> render(:new)
  end
end

Fingerprint-Based Rate Limiting

IP alone isn't enough — use browser fingerprinting for additional signal.

// Client-side: generate a fingerprint hash
// Use a library like FingerprintJS (free tier available)
import FingerprintJS from '@fingerprintjs/fingerprintjs';
const fp = await FingerprintJS.load();
const result = await fp.get();
document.getElementById('fp').value = result.visitorId;
# Server: rate limit per fingerprint too
case Hammer.check_rate("signup:fp:#{params["fp"]}", 86_400_000, 3) do
  {:allow, _} -> proceed()
  {:deny, _} -> block()
end

Layer 4: CAPTCHA (Visible Friction)

Use only when invisible layers aren't enough, or as escalation for suspicious behavior.

Progressive CAPTCHA

Don't show CAPTCHA to everyone — only when risk signals are present.

def needs_captcha?(conn, params) do
  ip = conn.remote_ip |> :inet.ntoa() |> to_string()

  cond do
    # High signup rate from this IP
    Hammer.check_rate_inc("signup_check:#{ip}", 3_600_000, 10) == {:deny, 10} -> true
    # Disposable email domain
    disposable_email?(params["email"]) -> true
    # Missing JS token (possible bot)
    is_nil(params["js_token"]) -> true
    # Default: no captcha needed
    true -> false
  end
end

CAPTCHA Options (ranked by UX):

  1. Cloudflare Turnstile — invisible/managed, free, privacy-friendly
  2. hCaptcha — privacy-focused, pays publishers
  3. ALTCHA — self-hosted, GDPR-compliant, proof-of-work based
  4. reCAPTCHA v3 — invisible scoring (but Google tracking concerns)
  5. reCAPTCHA v2 — checkbox/image challenges (most friction)

Layer 5: Post-Signup Detection

Catch spam that gets through initial defenses.

Behavioral Signals

defmodule MyApp.SpamDetection do
  def spam_score(user) do
    score = 0

    # No profile completed within 24 hours
    score = if is_nil(user.name), do: score + 1, else: score

    # No meaningful actions taken
    score = if user.actions_count == 0 and hours_since_signup(user) > 2,
      do: score + 2, else: score

    # Suspicious email patterns (random strings)
    score = if random_looking_email?(user.email), do: score + 2, else: score

    # Signed up from known VPN/proxy
    score = if vpn_ip?(user.signup_ip), do: score + 1, else: score

    score
  end

  defp random_looking_email?(email) do
    local = email |> String.split("@") |> List.first()
    # High ratio of digits to letters, or very long random strings
    digit_ratio = local |> String.graphemes() |> Enum.count(&(&1 =~ ~r/\d/)) |> Kernel./(String.length(local))
    digit_ratio > 0.5 or String.length(local) > 20
  end
end

Automated Cleanup

# Oban job to clean unconfirmed accounts
defmodule MyApp.Workers.CleanupUnconfirmed do
  use Oban.Worker, queue: :maintenance

  @impl Oban.Worker
  def perform(_job) do
    cutoff = DateTime.utc_now() |> DateTime.add(-72, :hour)

    from(u in User,
      where: is_nil(u.confirmed_at),
      where: u.inserted_at < ^cutoff
    )
    |> Repo.delete_all()

    :ok
  end
end

Implementation Checklist

Minimum Viable Protection (Start Here)

  • Honeypot field on signup form
  • Email confirmation required before account activation
  • Disposable email domain blocking
  • IP-based rate limiting (5 signups/hour per IP)
  • Clean up unconfirmed accounts after 72 hours

Enhanced Protection

  • Time-based form submission detection
  • JavaScript challenge token
  • MX record validation
  • Progressive CAPTCHA (Cloudflare Turnstile)
  • Browser fingerprint rate limiting

Advanced Protection

  • Post-signup behavioral scoring
  • VPN/proxy detection
  • Phone number verification for high-value actions
  • Machine learning anomaly detection
  • Manual review queue for borderline cases

Monitoring & Metrics

Track these to know if your defenses are working:

MetricWhat It Tells You
Signup-to-confirmation rateLow = spam getting through signup
Confirmation-to-activation rateLow = real users having trouble
Signups per IP per hourSpikes = bot attack
Disposable email block rateRising = targeted by spam
CAPTCHA trigger rateToo high = too aggressive; too low = too lenient
False positive rateReal users being blocked (monitor support tickets)

Common Mistakes

  • Blocking too aggressively — false positives lose real customers. Start lenient, tighten based on data.
  • Relying on CAPTCHA alone — bots solve CAPTCHAs. Layer defenses.
  • Revealing detection — don't tell bots they were caught. Return fake success responses.
  • Hardcoding disposable domains — the list grows daily. Use an updated source or API.
  • No monitoring — you won't know defenses failed until spam is everywhere.
  • Blocking VPNs entirely — many legitimate users use VPNs. Use as a signal, not a block.

Related Skills

  • saas-security: Broader security (auth, API protection, account takeover)
  • stripe-integration: Payment verification as anti-spam signal
  • email-sequence: Confirmation and verification email flows

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

elixir-tdd

No summary provided by upstream source.

Repository SourceNeeds Review
General

elixir-otp

No summary provided by upstream source.

Repository SourceNeeds Review
General

rust-tdd

No summary provided by upstream source.

Repository SourceNeeds Review
General

rust-async

No summary provided by upstream source.

Repository SourceNeeds Review