A single disputed order rarely arrives alone. When a fraud chargeback lands, the account behind it is often one node in a small network of accounts that share a phone, a drop address, or a payment instrument. Link analysis means starting from one confirmed bad account and finding the others. It is one of the highest-leverage tools a fraud or payments engineering team can build.
It is also easy to build badly. A naive implementation either finds nothing or links your whole customer base through a single carrier-grade NAT IP. This article covers how to model the problem, how to normalize identifiers, how to stop hub nodes from poisoning the results, and how to score what you find. For the dispute-side context on why these cases are hard to win and when to fight them, see this chapter on second-party fraud.
Model It as a Bipartite Graph
Don't draw edges directly between accounts. Model two node types:
- Account nodes: your customer accounts.
- Identifier nodes: typed values such as
(device, fp_9a3c),(card, fingerprint_77e1),(address, 12 elm st apt 4 springfield 01101),(phone, +15551234567),(email, jdoe@example.com),(ip24, 203.0.113.0/24).
An edge means "this account used this identifier." Two accounts are linked when they share an identifier node.
This structure has three practical benefits:
- You can see why two accounts are linked, which you need for analyst review and for any dispute evidence.
- You can measure an identifier's degree, meaning how many accounts touch it. That number is your main defense against false positives.
- Adding a new identifier type is just a new
kind, with no schema change.
Normalize Before You Link
Link analysis is only as good as your equality checks. Raw values almost never match across accounts, so normalize each type deliberately.
| Identifier | Normalization | Pitfall |
|---|---|---|
Lowercase. Strip +tag suffixes. Apply provider-specific rules such as ignoring dots for providers that ignore them | Applying dot-stripping to providers that treat dots as significant | |
| Phone | Convert to E.164 | Numbers stored without a country code |
| Address | Lowercase, expand or collapse abbreviations (street→st), strip punctuation, keep the unit number | Dropping the apartment number merges a whole building |
| IP | Group IPv4 to /24 and IPv6 to /64 | Treating a grouped IP as strong evidence |
| Card | Use your processor's card fingerprint or token, never a raw PAN | Storing PANs in a fraud database creates PCI scope |
| Device | Use your fingerprinting vendor's stable ID | Fingerprints drift after browser or OS updates |
A minimal normalizer for the trickier types:
import ipaddress, re ABBREV = {"street": "st", "avenue": "ave", "road": "rd", "apartment": "apt", "suite": "ste"} def norm_ip(ip: str) -> str: addr = ipaddress.ip_address(ip) prefix = 24 if addr.version == 4 else 64 return str(ipaddress.ip_network(f"{ip}/{prefix}", strict=False)) def norm_address(line1, line2, city, postal): raw = " ".join([line1, line2 or "", city, postal]).lower() raw = re.sub(r"[^a-z0-9 ]", " ", raw) tokens = [ABBREV.get(t, t) for t in raw.split()] return " ".join(tokens) def norm_email(email: str) -> str: local, domain = email.lower().split("@", 1) local = local.split("+", 1)[0] return f"{local}@{domain}" In production, use a proper address-standardization service rather than regex. The point here is the principle: decide what "same" means for each type, and write it down.
Weight Edges by How Much They Prove
Not all shared identifiers mean the same thing. A shared card fingerprint across two "unrelated" accounts is close to conclusive. A shared /24 subnet means almost nothing alone.
Assign each identifier type two numbers:
- Weight: how strongly a shared value suggests the same actor.
- Hub cap: the degree above which a value is probably shared infrastructure rather than a shared person.
WEIGHTS = {"card": 1.0, "device": 0.9, "address": 0.8, "phone": 0.7, "email": 0.6, "ip24": 0.2} HUB_CAP = {"card": 3, "device": 5, "address": 8, "phone": 3, "email": 2, "ip24": 25} These values are starting points. Tune them against confirmed cases from your own data.
Traversal With Hub Suppression
The expansion is a bounded breadth-first search from the seed account. Two rules matter:
- Skip hub identifiers. If an identifier touches more accounts than its cap allows, don't traverse through it. Shared mobile-carrier IPs, corporate VPN exits, reshipping warehouses, and popular pickup lockers are all hubs.
- Only strong edges propagate. Weak edges, such as a shared subnet, are recorded as supporting evidence but don't pull new accounts into the frontier.
from collections import defaultdict, deque class LinkGraph: def __init__(self): self.acct_to_ids = defaultdict(set) self.id_to_accts = defaultdict(set) def add(self, account_id, kind, value): node = (kind, value) self.acct_to_ids[account_id].add(node) self.id_to_accts[node].add(account_id) def expand(self, seed, max_hops=2, min_weight=0.6): hops_of = {seed: 0} evidence = defaultdict(list) # account -> [(via_account, identifier)] q = deque([seed]) while q: acct = q.popleft() if hops_of[acct] >= max_hops: continue for node in self.acct_to_ids[acct]: kind, _ = node linked = self.id_to_accts[node] if len(linked) > HUB_CAP[kind]: continue for other in linked: if other == acct: continue evidence[other].append((acct, node)) if other not in hops_of and WEIGHTS[kind] >= min_weight: hops_of[other] = hops_of[acct] + 1 q.append(other) return hops_of, evidence Scoring linked accounts
For each discovered account, combine its edge weights with a noisy-OR. This treats each link as independent evidence, so several moderate links add up without going past 1.0:
def link_score(edges): p_not = 1.0 for _, (kind, _) in edges: p_not *= (1 - WEIGHTS[kind]) return 1 - p_not An account sharing only an email (0.6) scores 0.6. An account sharing a phone and an address scores 1 - (0.3 × 0.2) = 0.94. Accounts above a high threshold go to automated action. Those in the middle go to an analyst queue.
Worked Example
Account A1 receives a fraud chargeback. You snapshot its identifiers and run the expansion.
- A1 used device
D1.D1is also on A2 and A3, so the degree is 3, under the cap of 5. Both are added at hop 1. - A3 shipped to address
X.Xis also on A4, with a degree of 2. A4 is added at hop 2. - A4 logged in from
198.51.100.0/24, which is shared by 140 accounts. That exceeds the cap of 25, so it is skipped. - A2 shares
203.0.113.0/24with A5. This is a weak edge (0.2), so A5 is recorded as evidence but not traversed.
The result:
| Account | Hop | Links | Score | Action |
|---|---|---|---|---|
| A2 | 1 | device | 0.90 | Auto-review, hold orders |
| A3 | 1 | device | 0.90 | Auto-review, hold orders |
| A4 | 2 | address | 0.80 | Analyst queue |
| A5 | — | ip24 | 0.20 | Log only |
Without hub suppression, the 140-account subnet would have pulled in a large slice of a mobile carrier's customers. That is the most common way these systems lose analyst trust.
Data Flow: Where This Runs
A practical pipeline looks like this:
- Capture. At order time, write normalized identifiers to an append-only events table with timestamps. Don't overwrite.
- Snapshot on trigger. When a dispute or fraud flag arrives, freeze the seed account's identifiers before anyone closes the account or deletes payment methods. Remediation steps often destroy the very data you need.
- Expand. Run the bounded traversal against the identifier index. A relational database with indexes on
(kind, value)is enough at moderate scale. A graph database becomes useful when you need many hops or interactive exploration. - Score and route. High scores trigger blocks or order holds. Mid-range scores go to analysts, along with the evidence paths.
- Feed back. Analyst verdicts become labels for tuning weights and caps.
Common Mistakes and How to Catch Them
Ignoring time. An address shared by two accounts three years apart may simply be a new tenant. Add a time window to edges, or decay weights with age. You can catch this by sampling linked pairs and checking how far apart their activity was.
Static hub caps. A drop address used by a fraud ring can legitimately exceed a cap, since that is what a drop is. Before skipping a high-degree node, check whether it is a known hub (a carrier IP range or a published reshipper address) or an unknown one. Unknown high-degree addresses are worth an analyst's look rather than a silent skip.
Households look like rings. Family members share devices, addresses, and sometimes cards. In second-party fraud the person misusing the account is often close to the cardholder, so this is signal, not noise. It still shouldn't trigger automatic blocks on every relative. Route household-shaped clusters, meaning a single address with shared devices, to humans.
Unbounded hops. Every extra hop multiplies the candidate set. Keep max_hops at 2 for automated actions. Allow analysts to expand further manually.
Fingerprint drift. A ring member who updates their browser may get a new device ID. Blocking only on device misses them. Blocking on the device plus the drop address plus the card fingerprint is far more durable.
No measurement. Track precision on analyst-reviewed clusters per identifier type. If email links are confirmed only a third of the time, lower the weight.
What You Get Out of It
A working link-analysis pipeline turns each confirmed fraud case into three outputs: a set of accounts to hold or block, a set of non-disposable identifiers to blocklist, and labeled data that improves your rules. The chargeback itself may be unrecoverable. The cluster behind it usually isn't.
