The self-referral problem
Someone signs up for your affiliate program, grabs their referral link, opens a private window, creates a second account with a different email, and pays for a subscription through their own link. Now they collect a commission on the money they just paid you. If your commission is recurring, they have effectively given themselves a permanent discount and you are paying the processing fees for the privilege.
The variant that is harder to argue with: the affiliate has a real second business, or a family member signs up, or an agency refers a client and then pays the client's bill. Some of that is legitimate. Some of it is not. Either way you need to see it before you pay out, not after.
The signals most programs check are the ones easiest to defeat
Almost every affiliate system I have looked at checks three things: email address, IP address, and some flavour of device fingerprint.
A second email costs nothing and takes ten seconds. An IP address is one VPN toggle away, and residential proxy pools are sold by the gigabyte. Device fingerprints get defeated by a fresh browser profile, and anti-detect browsers exist specifically to sell that capability to people who want it.
There is a pattern here. The signals that are cheap for you to collect are also cheap for the other side to produce. If you want a check that actually costs the attacker something, you have to find something they cannot mint on demand.
The card is the expensive signal
Fifty email addresses cost nothing. Fifty distinct working payment cards, each attached to a real funding source, is a different kind of problem. That asymmetry is the whole reason card data is worth checking.
Stripe exposes a fingerprint field on the card object of a PaymentMethod. It is an opaque string Stripe generates from the underlying card number, and it stays stable for that card across different Customer records. Two accounts with different emails, different IPs, and different browsers still produce the same fingerprint when the same physical card pays both bills.
One thing to be precise about, because people build the wrong thing here: the fingerprint is consistent within a single Stripe account. It is not a global identifier shared between merchants. You cannot ask another company whether they have seen this card, and you cannot compare fingerprints across two of your own Stripe accounts. For self-referral detection that limitation does not matter, because you are only ever comparing customers inside your own account.
Reading and storing the fingerprint
You can read it off a PaymentMethod directly:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const pm = await stripe.paymentMethods.retrieve(paymentMethodId);
const fingerprint = pm.card?.fingerprint ?? null;
const funding = pm.card?.funding ?? "unknown";
It also appears on a Charge, which is useful if you are backfilling from historical payments rather than instrumenting signup:
const charge = await stripe.charges.retrieve(chargeId);
const fp = charge.payment_method_details?.card?.fingerprint ?? null;
Store the fingerprint, never the card number. The fingerprint is an opaque Stripe-generated string with no card data inside it, so persisting it in your own database is safe and does not drag you into extra PCI scope. Keep it in a table that allows several rows per user, because people legitimately change cards.
create table user_card_fingerprints (
user_id uuid not null,
fingerprint text not null,
funding text not null default 'unknown',
first_seen timestamptz not null default now(),
primary key (user_id, fingerprint)
);
Write to it from a webhook, so you capture cards attached outside your own checkout flow:
// invoice.payment_succeeded / setup_intent.succeeded handler
async function recordFingerprint(userId: string, pm: Stripe.PaymentMethod) {
const fp = pm.card?.fingerprint;
if (!fp) return; // non-card methods and some tokens have none
await db.query(
`insert into user_card_fingerprints (user_id, fingerprint, funding)
values ($1, $2, $3)
on conflict (user_id, fingerprint) do nothing`,
[userId, fp, pm.card?.funding ?? "unknown"]
);
}
The check at conversion time
When a referred customer converts, compare their fingerprints against every fingerprint on record for the affiliate who referred them. Return a signal with reasons attached, not a boolean. You will want the reasons later, when a real affiliate emails you asking why their commission is on hold.
type RiskLevel = "low" | "review" | "high";
interface SelfReferralSignal {
level: RiskLevel;
reasons: string[];
matchedFingerprints: string[];
}
export async function scoreSelfReferral(
referredUserId: string,
affiliateUserId: string
): Promise<SelfReferralSignal> {
const [buyer, affiliate] = await Promise.all([
getFingerprints(referredUserId),
getFingerprints(affiliateUserId),
]);
const affiliateSet = new Set(affiliate.map((c) => c.fingerprint));
const matched = buyer.filter((c) => affiliateSet.has(c.fingerprint));
const reasons: string[] = [];
let level: RiskLevel = "low";
if (matched.length > 0) {
reasons.push("Buyer paid with a card already on file for the affiliate.");
level = "high";
}
if (buyer.some((c) => c.funding === "prepaid")) {
reasons.push("Buyer paid with a prepaid card.");
if (level === "low") level = "review";
}
return { level, reasons, matchedFingerprints: matched.map((c) => c.fingerprint) };
}
Three limits, and they are not small
Virtual cards defeat this outright. Services that mint a fresh virtual card per merchant give each one a distinct card number, which produces a distinct fingerprint. Ten virtual cards look like ten unrelated strangers. There is no clever query that recovers the link, because the link does not exist in the data you have. Anyone determined enough to run a self-referral ring at scale will find this hole, and the check is worthless against them.
Prepaid cards are a visible signal, which is a different situation. Stripe reports funding on the card object with values of credit, debit, prepaid, or unknown. A prepaid card does not tell you fraud is happening, and plenty of legitimate customers pay that way. It belongs in a look-closer tier:
const funding = pm.card?.funding; // "credit" | "debit" | "prepaid" | "unknown"
if (funding === "prepaid") flagForReview(userId, "prepaid_funding");
Shared corporate cards produce false positives, cutting the opposite way. Five colleagues expensing seats on one company card collapse into a single actor under fingerprint matching. If one of them joined your affiliate program and referred the other four, the naive check accuses an entirely honest person. I would rather ship a system that misses some fraud than one that emails a good affiliate to tell them their commission is cancelled for something they did not do.
Combine weak signals, then defend the decision
The question worth asking is not which identifier is perfect. None of them are. The question is how you combine several weak signals into a decision you can explain, in plain sentences, to a legitimate affiliate you are about to accuse.
Card signals earn their place because they are expensive to fake, not because they prove anything on their own. Build three tiers. Low risk pays out normally. Suspicious holds the commission and creates a review item with the reasons attached. Clear-cut, meaning an exact fingerprint match plus supporting evidence like a matching billing name or a signup minutes after the referral click, gets blocked with a written explanation. Never auto-claw-back on a single signal, because the one time you are wrong you will burn an affiliate who was sending you real customers.
The cost, stated plainly
Requiring a card before a trial measurably reduces signups. That is why plenty of product-led teams refuse to do it, and they are not being sloppy. Whether the trade pays off depends on what the abused resource costs you. For a cheap-to-serve SaaS seat, eating some self-referral is usually cheaper than the signups you lose at the gate. For expensive compute where every trial burns real money, the card requirement often pays for itself immediately.
I work on affiliate software for SaaS built on Stripe, which is how I ended up staring at fingerprint fields for longer than anyone should.
Top comments (0)