Preventing Payment Fraud

The essential playbook for implementing preventing payment fraud in your SaaS.

This page outlines a minimum viable fraud-prevention setup for small SaaS products. The goal is to block obvious abuse early, collect enough signals for review, and avoid breaking legitimate checkout flows.

Focus on layered controls:

  • payment provider risk checks
  • webhook-based state updates
  • account verification
  • rate limits
  • internal audit logs

This page assumes a Stripe-based SaaS flow and app-side access control.

Quick Fix / Quick Setup

Use provider fraud tools first, then add app-level controls. Do not build custom card risk scoring before enabling Stripe Radar, webhook verification, email verification, and endpoint throttling.

bash
# Minimum fraud-prevention checklist for Stripe-based SaaS
# 1) Never trust client-side payment success
# 2) Activate Stripe Radar rules in dashboard
# 3) Require webhook-confirmed payment state before enabling access
# 4) Rate limit signup, checkout, coupon apply, and card update endpoints
# 5) Verify email before granting trial/credits-sensitive actions
# 6) Store customer_id, payment_method fingerprint/signals, IP, user_agent, country
# 7) Flag mismatches: IP country != card country != billing country
# 8) Block disposable email domains and repeated failed attempts
# 9) Review high-risk payments manually before provisioning expensive resources
# 10) Log every subscription/payment status transition

Example server-side access gate:

python
if subscription.status not in {"active", "trialing"}:
    deny_feature_access()

Webhook rule of thumb:

txt
Provision account only after receiving:
- invoice.paid
or
- checkout.session.completed

And only after:
- verifying event signature
- validating the linked customer/subscription state
- preventing duplicate processing

Minimum Stripe CLI checks:

bash
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger payment_intent.payment_failed

What’s happening

Payment fraud in SaaS usually appears as:

  • stolen card usage
  • card testing
  • fake trial abuse
  • refund abuse
  • disposable-email account creation
  • repeated signups to consume credits, seats, or API quotas

Most fraud losses happen when the app grants access too early, before a verified payment event or before enough user signals are collected.

A safe setup uses multiple layers:

  • provider-side fraud screening
  • application-side verification
  • rate limiting
  • review queues
  • clear subscription state handling
Checkout
Payment
Webhook
Verify
Access

Payment Flow

Step-by-step implementation

1) Define fraud-sensitive actions

List actions that cost money or create abuse risk:

  • trial creation
  • subscription activation
  • coupon redemption
  • seat expansion
  • file storage allocation
  • API credit grants
  • high-cost job execution
  • refunds
  • payment method changes

If an action has financial impact, do not let it run from frontend state alone.

2) Treat Stripe as source of truth for payment state

Only mark subscriptions active after verified server-side events.

Typical events to use:

  • checkout.session.completed
  • invoice.paid
  • customer.subscription.updated
  • charge.dispute.created
  • invoice.payment_failed

Do not use browser redirect success as proof of payment.

Bad:

ts
// wrong
if (query.success === "true") {
  enablePaidFeatures(userId)
}

Better:

ts
// right
if (subscription.status === "active" || subscription.status === "trialing") {
  enablePaidFeatures(userId)
}

3) Verify webhook signatures and enforce idempotency

Reject unsigned or replayed events. Store processed event IDs.

Example Node/Express webhook handler:

ts
import express from "express"
import Stripe from "stripe"

const app = express()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!

app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const sig = req.headers["stripe-signature"]

    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(req.body, sig!, webhookSecret)
    } catch (err) {
      return res.status(400).send(`Webhook Error`)
    }

    const existing = await db.paymentWebhookEvents.findUnique({
      where: { eventId: event.id },
    })

    if (existing) {
      return res.status(200).json({ received: true, duplicate: true })
    }

    await db.paymentWebhookEvents.create({
      data: {
        eventId: event.id,
        eventType: event.type,
        payload: JSON.stringify(event),
      },
    })

    switch (event.type) {
      case "checkout.session.completed":
      case "invoice.paid":
      case "customer.subscription.updated":
        await syncBillingStateFromStripe(event)
        break

      case "invoice.payment_failed":
      case "charge.dispute.created":
        await markAccountForReview(event)
        break
    }

    return res.status(200).json({ received: true })
  }
)

Recommended DB table:

sql
create table payment_webhook_events (
  id bigserial primary key,
  event_id text not null unique,
  event_type text not null,
  processed_at timestamptz not null default now(),
  payload jsonb not null
);

4) Enable Stripe fraud controls

At minimum:

  • enable Stripe Radar default protections
  • review AVS and CVC checks
  • inspect risk evaluations in dashboard
  • route elevated-risk payments to manual review if needed

Operational rule:

  • clear abuse: block automatically
  • borderline risk: review before provisioning expensive resources
  • low risk: continue with normal webhook-confirmed activation

App-side policy still matters. Radar does not stop:

  • trial farming
  • coupon brute force
  • team invite abuse
  • repeated card update spam
  • API credit abuse after account creation

5) Add endpoint rate limiting

Protect:

  • signup
  • login
  • password reset
  • checkout session creation
  • coupon submission
  • payment method update
  • trial start
  • invite flows
  • API key generation

Example NGINX edge limit:

nginx
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=billing_limit:10m rate=10r/m;

server {
  location /api/auth/ {
    limit_req zone=auth_limit burst=10 nodelay;
    proxy_pass http://app;
  }

  location /api/billing/ {
    limit_req zone=billing_limit burst=20 nodelay;
    proxy_pass http://app;
  }
}

Example app-side limit policy:

txt
Anonymous:
- signup: 5/hour/IP
- create checkout session: 10/hour/IP
- apply coupon: 10/hour/IP
- payment method update: 5/hour/IP

Authenticated:
- create checkout session: 20/hour/account
- apply coupon: 20/hour/account
- card update after repeated failures: stricter threshold

6) Gate expensive provisioning

Do not instantly allocate expensive resources after checkout redirect success.

Provision only after:

  • webhook-confirmed state
  • verified customer mapping
  • optional review for medium/high-risk accounts

Example:

ts
const allowedStatuses = new Set(["active", "trialing"])

if (!allowedStatuses.has(subscription.status)) {
  throw new Error("Billing not active")
}

if (account.reviewState === "pending_review" || account.reviewState === "blocked") {
  throw new Error("Account under review")
}

await provisionWorkspace(account.id)

Use internal review states:

  • pending_review
  • suspicious
  • blocked
  • approved
  • support_override

7) Add identity friction where needed

Require email verification before:

  • trial activation for resource-intensive plans
  • team creation
  • invite sending
  • API key generation
  • coupon usage if abuse is common

Also block disposable email domains.

Example pseudocode:

ts
if (!user.emailVerified) {
  throw new Error("Verify email before starting trial")
}

if (isDisposableEmail(user.email)) {
  throw new Error("Disposable email addresses are not allowed")
}

For a full implementation, see Email Verification Flow (Step-by-Step).

8) Store audit data centrally

Persist enough data to investigate abuse and respond to disputes.

Minimum fields:

  • user_id
  • customer_id
  • subscription_id
  • checkout_session_id
  • payment_intent_id
  • invoice_id
  • IP address
  • user agent
  • email domain
  • billing country
  • card country if available
  • provider risk level if exposed
  • account review state
  • coupon codes used
  • event timestamps
  • support actions
  • dispute references

Example schema:

sql
create table payment_audit_log (
  id bigserial primary key,
  user_id uuid,
  provider_customer_id text,
  subscription_id text,
  event_type text not null,
  risk_level text,
  ip inet,
  user_agent text,
  email text,
  email_domain text,
  billing_country text,
  card_country text,
  metadata jsonb not null default '{}'::jsonb,
  created_at timestamptz not null default now()
);

9) Add app-level abuse rules

Useful minimum rules:

  • do not allow unlimited free trials per email, IP, device fingerprint, or org domain
  • limit payment method changes after repeated failed attempts
  • require re-authentication for refunds and card changes
  • delay access to high-cost features for medium/high-risk accounts
  • do not grant credits from frontend success pages
  • keep internal audit records even if the user deletes the account

Example trial guard:

ts
const recentTrials = await db.trials.count({
  where: {
    OR: [
      { emailHash: hash(email) },
      { ipAddress: ip },
      { orgDomain },
      { deviceHash },
    ],
    createdAt: { gte: thirtyDaysAgo },
  },
})

if (recentTrials > 0) {
  denyTrial()
}

10) Handle disputes and failed payments separately

Do not treat all failed payments as fraud. Separate:

  • authentication issues
  • insufficient funds
  • expired cards
  • stolen card usage
  • post-payment disputes

Recommended behavior:

  • invoice.payment_failed: pause upgrades, request update, do not blindly revoke everything instantly unless policy requires it
  • charge.dispute.created: freeze risky expansion actions, preserve evidence, alert support/admin
  • repeated failures from linked accounts: increase scrutiny and rate limits

11) Test attack scenarios in staging

Simulate:

  • duplicate webhooks
  • delayed webhook delivery
  • checkout success redirect with no webhook processing
  • repeated failed payments from one IP
  • repeated trials from disposable domains
  • coupon brute force
  • card update spam

Useful commands:

bash
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger payment_intent.payment_failed
curl -i https://yourapp.com/api/billing/create-checkout-session
curl -i https://yourapp.com/api/billing/webhook
grep -R "checkout.session.completed\|invoice.paid\|charge.dispute.created" ./logs
grep -R "rate limit\|429\|blocked\|suspicious" ./logs
psql "$DATABASE_URL" -c "select user_id, provider_customer_id, status, updated_at from subscriptions order by updated_at desc limit 20;"
psql "$DATABASE_URL" -c "select event_id, event_type, processed_at from payment_webhook_events order by processed_at desc limit 20;"

12) Document escalation paths

Define:

  • when to auto-block
  • when to require manual review
  • who can override a block
  • what evidence is stored
  • how disputes are answered
  • how false positives are reversed safely

This prevents ad hoc fixes and inconsistent support behavior.

Common causes

Most fraud-related billing failures or abuse leaks come from a small set of implementation mistakes:

  • granting paid access before webhook-confirmed payment success
  • missing or weak rate limits on signup and checkout endpoints
  • no email verification, allowing throwaway account abuse
  • ignoring subscription states like incomplete, incomplete_expired, past_due, or unpaid
  • no idempotency in webhook handling, causing duplicate provisioning
  • no audit logs for disputes, failed payments, and suspicious account patterns
  • allowing repeated trials, coupon abuse, or credit abuse across linked accounts
  • over-reliance on frontend success pages or client-side payment state

Also common:

  • trusting subscription.created without checking invoice/payment state
  • provisioning from checkout.session.completed when your app actually needs invoice.paid
  • not linking Stripe customer records correctly to internal users
  • deleting evidence after account deletion or cancellation

Debugging tips

Check whether account access was granted by frontend redirect logic instead of server-side payment status.

Compare your internal state with Stripe:

  • customer
  • subscription
  • invoice
  • charge
  • dispute
  • payment intent

Review webhook logs first when fraud controls look inconsistent. Many payment-state bugs are actually webhook handling bugs.

Useful commands:

bash
stripe listen --forward-to localhost:8000/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.paid
stripe trigger payment_intent.payment_failed

curl -i https://yourapp.com/api/billing/create-checkout-session
curl -i https://yourapp.com/api/billing/webhook

grep -R "checkout.session.completed\|invoice.paid\|charge.dispute.created" ./logs
grep -R "rate limit\|429\|blocked\|suspicious" ./logs

psql "$DATABASE_URL" -c "select user_id, provider_customer_id, status, updated_at from subscriptions order by updated_at desc limit 20;"
psql "$DATABASE_URL" -c "select event_id, event_type, processed_at from payment_webhook_events order by processed_at desc limit 20;"

Audit suspicious accounts by:

  • IP clusters
  • email domain
  • coupon usage
  • customer ID linkage
  • creation timestamp clusters
  • repeated payment failures
  • geo mismatches between IP, billing country, and card country
Checkout
Payment
Webhook
Verify
Access

Payment Flow

Checklist

  • Webhook signature verification enabled
  • Idempotent webhook processing implemented
  • Access granted only from verified server-side payment state
  • Stripe Radar or equivalent provider controls enabled
  • Rate limits applied to auth and payment endpoints
  • Email verification required for abuse-prone flows
  • Disposable email filtering configured
  • Audit logs stored for payment and subscription changes
  • Manual review path exists for high-risk accounts
  • Dispute and chargeback handling documented
  • Coupon/trial abuse limits implemented
  • Support can override false positives safely

Cross-check with:

Related guides

FAQ

What is the minimum anti-fraud setup for an MVP SaaS?

Enable Stripe fraud controls, verify webhooks, grant access only from server-side payment state, rate-limit auth and payment endpoints, and require email verification for trial or credit-sensitive actions.

Should I activate accounts on checkout success redirect?

No. The redirect only tells you the browser returned. Always wait for a verified webhook event and update your internal billing state first.

How do I reduce trial abuse without hurting conversion?

Require verified email, limit trials per account cluster, watch disposable domains, and delay expensive resource provisioning until the account passes basic checks.

What should I log for fraud investigation?

Log customer ID, subscription ID, event IDs, IP, user agent, billing details, risk flags, state transitions, coupon use, disputes, and support actions.

Should I block all high-risk payments automatically?

Usually no. Auto-block clear abuse, but send borderline cases to manual review if false positives would hurt conversions.

Can I rely only on Stripe Radar?

No. Provider tooling handles payment risk, but app-side abuse still happens through trials, credits, coupon abuse, and premature access grants.

When should I enable access after checkout?

After a verified webhook updates your internal subscription or invoice state, not only after the browser returns from checkout.

Do I need device fingerprinting?

Not at first. Start with provider risk tools, rate limits, IP/account linkage, email verification, and audit logs before adding more invasive tracking.

Final takeaway

Fraud prevention for small SaaS products is mostly about correct state handling and layered controls, not complex machine learning.

Do this first:

  • use provider risk tools
  • trust webhooks over the frontend
  • rate-limit abuse points
  • verify user identity where it matters
  • keep enough audit data to review suspicious activity

If those controls are in place before launch, you will block most early-stage fraud patterns without damaging legitimate checkout flow.