DEV Community

Daniel Pertu
Daniel Pertu

Posted on

The fingerprint we deliberately do not use to catch referral fraud

We added a referral scheme. Refer someone, they buy something, you get a reward. The reward is granted immediately, with no thirty day payout hold, because a hold is a worse product and we would rather solve the problem than delay it.

Granting immediately means the fraud check has to be correct at grant time. There is no later batch job quietly filtering out the bad ones before anything moves.

The threat we actually care about is narrow and specific: one person, two accounts, funding their own referral. Not organised fraud rings, not stolen cards. One person deciding the scheme is a discount if they are willing to make a second email address.

The signal that looked right

We already had a device_sessions table with a device_fingerprint column, built for something else. It was sitting there, populated and indexed. Two accounts with the same device fingerprint sounds exactly like the check you want.

Then I read how the column was computed. It was sha256(user-agent). No canvas, no fonts, no per-user salt, no client entropy of any kind. One hash of one header.

Think about what that identifies today. Chrome's User-Agent Reduction has been in effect for years: the string is deliberately frozen and coarse. Every Chrome user on the same major version and OS family sends a near-identical value. On mobile it is worse, because those strings were normalised harder still.

So sha256(user-agent) does not identify a device. It identifies roughly "Chrome on Windows". Two completely unrelated people collapse to the same fingerprint.

Had we gated referral rewards on it, the result would have been:

  • A large share of legitimate referrals silently denied. A student refers a coursemate. Both on Chrome on Windows, because of course they are. Identical fingerprint, reward withheld, no explanation the user can act on.
  • Zero cost to an actual farmer, who evades it by sending a different User-Agent. That is a one line change in any HTTP client, or two clicks in devtools.

That is the worst possible signal: high false positives against honest users, trivially evaded by the people it targets. Security theatre with a false positive rate.

So the code carries a comment explaining why the tempting table is not consulted, because the next person to open this file will have the same idea I did:

// NOTE: we deliberately do NOT gate on device_sessions.device_fingerprint.
// That value is sha256(user-agent) with no per-user salt, so, thanks to
// Chrome's User-Agent Reduction and uniform mobile UAs, two UNRELATED accounts
// on the same browser/OS collapse to an identical fingerprint. Rejecting on it
// would silently deny rewards to a large share of legitimate referral pairs,
// while a real farmer evades it by sending a different user-agent.
Enter fullscreen mode Exit fullscreen mode

A comment that says "we tried this and here is why it is wrong" is worth more than most of the code around it.

The signal that actually works

The one cross account identity signal we hold is the payment card fingerprint Stripe returns on a payment method. It has the properties the UA hash lacks:

  • It is stable for one physical card across different Stripe customers and different accounts.
  • It is not user supplied. The person committing the fraud cannot pick it.
  • Changing it means genuinely using a different card, which is a real cost rather than a header edit.

The important design decision is when to record it. We write a fingerprint row on every successful payment, not only referral ones:

export async function recordCardFingerprint(
  userId: string,
  cardFingerprint: string | null,
  executor: DbExecutor = db,
  now: Date = new Date()
): Promise<void> {
  if (!cardFingerprint) return;

  await executor
    .insert(paymentCardFingerprintsTable)
    .values({
      id: crypto.randomUUID(),
      user_id: userId,
      card_fingerprint: cardFingerprint,
      first_seen_at: now,
      last_seen_at: now,
    })
    .onConflictDoUpdate({
      target: [
        paymentCardFingerprintsTable.user_id,
        paymentCardFingerprintsTable.card_fingerprint,
      ],
      set: { last_seen_at: now },
    });
}
Enter fullscreen mode Exit fullscreen mode

That is the whole trick. By the time somebody is a referrer, we already hold their card history from months of ordinary purchases, recorded before they had any reason to think about referrals. A signal you only start collecting at the moment you need it is a signal your adversary knows you are collecting.

The upsert makes it idempotent per account and card, so a repeat purchase just refreshes last_seen_at.

Four checks, cheapest first

// 1. Self-referral. Also a DB CHECK and a preview-time check; this is the
//    last line, in case a code owner changed hands or a request was forged.
if (input.referrerUserId === input.buyerUserId) return reject('self_referral', signals);

// 2. One reward per buyer, ever. A partial unique index backstops races.
// 3. Per-referrer lifetime cap, a backstop against mass farming.
// 4. Shared payment card, the core same-person-second-account signal.
Enter fullscreen mode Exit fullscreen mode

Check 4 has two parts. Does the referrer have this exact card on file, which is the direct "you paid for your own referral" case? And does this card appear on more accounts than MAX_ACCOUNTS_PER_CARD, which catches one card fanning out across several?

Checks 1 and 2 are each enforced in more than one place, and the redundancy is intentional. Self referral is a database CHECK constraint, a check when the code is first entered, and this final gate. One reward per buyer is a partial unique index as well as a query. The application check produces the good error message and the audit signal; the constraint is what actually holds under concurrency. If you only have one of the two, you have picked either bad messages or bad guarantees.

The count query for accounts sharing a card is deliberately unbounded, which felt wrong until I wrote the reason down: the index makes it a cheap scan, and a card that genuinely spans enough accounts for the row count to matter is itself the finding.

The part I would keep in any fraud system

Every check writes what it saw into a signals object, and that object is snapshotted onto the referral row whether or not the reward was granted:

const signals: Record<string, unknown> = {
  cardFingerprintPresent: Boolean(input.cardFingerprint),
};
// ...
signals.buyerAlreadyRewarded = Boolean(priorGrant);
signals.referrerGrantedCount = granted;
signals.referrerSharesCard = Boolean(referrerCard);
signals.accountsSharingCard = accounts;
Enter fullscreen mode Exit fullscreen mode
function reject(reason: string, signals: Record<string, unknown>): ReferralFraudDecision {
  return { ok: false, reason, signals: { ...signals, rejectedFor: reason } };
}
Enter fullscreen mode Exit fullscreen mode

Six months from now somebody will ask why their reward never arrived. Without the snapshot, answering means re-running the checks against data that has since changed, which is not an answer, it is a guess. With it, the row says what was true at decision time.

An automated rejection you cannot explain after the fact is not a decision, it is an outage with extra steps.

Where this sits in the product

The things a reward or a discount code can apply to are the provider unlocks and packs listed on the pricing page. Every list price there is resolved from code rather than from the request body, which is a separate piece of the same posture: a discount is always a percentage of a number we own, never a number the client sent.

If you want the practical version of this post, go and look at whichever anti-abuse signal you currently trust most, and find out how it is actually computed. Ours turned out to be a hash of a header that browser vendors spent five years deliberately making less informative.

Top comments (0)