Data minimization for fraud scoring (hashes, not PII hoarding)
Most fraud detection tools store raw PII. Emails in plain text, phone numbers in plain text, addresses in plain text, cross-referenced across customer profiles, indexed for fast lookup, available to anyone on the engineering or analytics team with read access to the database. The justification is always some version of "we need it for detection. We correlate emails across accounts, we match phone numbers to catch fraud rings."
That justification is wrong, and the architectural evidence against it has gotten clearer every year since 2020. You don't need raw PII to correlate. You need stable digests that preserve equality comparison. Hash the email at the edge. Store the digest. Correlate on digests. The fraud detection works identically. The blast radius of a database compromise goes from "every customer's email is now on pastebin" to "a bunch of sha256 digests that can't easily be reversed."
This post is the architecture. How RefundSentry handles fraud-correlation identity without storing plaintext PII in the general scoring tables. What gets hashed, where the hashing happens, how we handle GDPR erasure, and what trade-offs the design makes explicit.
Engineering-depth post. There's a companion compliance-focused GDPR piece that covers the legal framing. This one covers how the system is actually built.
The problem with plaintext PII in fraud tables
A fraud scoring model needs to answer questions like:
- "How many other customer accounts have used this email address?"
- "Does this phone number appear on any previous chargeback-linked order?"
- "Is this shipping address shared with accounts that have been flagged?"
The naive implementation stores the email, phone, and address as columns on a customer or order table, and runs SQL joins. Works. Easy to explain. Exactly the implementation most fraud tools have shipped for a decade.
It has four problems.
1. Blast radius on breach. A database compromise exposes every customer's contact information across every merchant using the tool. The incident that shows up in the press quarterly and ends up costing the vendor their largest accounts.
2. Employee access. Any engineer, analyst, or support person with read access to the scoring database can pull a list of every customer's email. The internal threat model most companies never write down.
3. GDPR/CCPA erasure complexity. Under GDPR Article 17 and CCPA equivalents, customers can request deletion of their personal data. If that data is spread across fraud tables, audit logs, scoring history, and correlation graphs, erasure is a multi-system coordinated operation. The risk of incomplete erasure (leaving stale PII somewhere) is high, and the regulatory penalty for incomplete erasure is real.
4. Legal discovery. Plaintext PII in your systems is discoverable in litigation. Hashed PII is materially less useful to a subpoena.
Each of these individually justifies hashing. Together they make plaintext PII storage indefensible for a fraud tool built in 2026.
The core idea: hashing preserves equality, discards everything else
A cryptographic hash function (SHA-256) produces a fixed-length digest from an input. The same input always produces the same digest. Different inputs produce different digests with overwhelmingly high probability.
For fraud correlation, that's exactly the property we need. "Do these two records refer to the same email address?" We don't need the email itself, we need a stable comparison key that says yes or no. SHA-256 digests give us that.
What SHA-256 deliberately doesn't give us:
- Reversibility. You can't recover the original email from the digest. Without a way to test candidate inputs, a digest leak is not a PII leak.
- Ordering or partial comparison. Two digests that differ by one bit are as different as two digests that share no bits. You can't do partial-match queries ("emails starting with
admin"). Only exact equality. - Substring search. "All customers with gmail.com emails" doesn't work on digests.
The last two are the trade-offs. For fraud correlation, which only needs exact equality, they're acceptable. For CRM-style features (marketing segmentation by email domain, substring search for support ticket handling), you can't use the fraud tables. You'd need a separate system with appropriate handling, which is how it should be anyway.
Where the hashing happens: the edge
The critical architectural decision: where in the data flow does the plaintext-to-hash conversion happen?
Wrong answer: "at rest, in the database." If plaintext arrives at the database and then gets hashed in a background job, you have a window where plaintext is on disk (in logs, in transaction journals, in backups) and a category of incidents where the plaintext leaks anyway. You've also built a system where "temporarily" storing the plaintext is an option that will creep into use.
Right answer: at the edge, in the ingestion pipeline, before any general-purpose persistence. The plaintext arrives from the Shopify webhook or API call, gets immediately hashed in memory, and the hash is what gets written to the fraud scoring tables. The plaintext is never persisted in the fraud path.
In RefundSentry, this is implemented as app/lib/identity/derive-from-return.ts → deriveCustomerIdentityHashes(). Every entry point that receives PII from Shopify (the real-time webhook path in app/routes/api.process-return.tsx and the historical backfill path in app/lib/backfill/processor.server.ts) calls this same helper. Neither route ever reads the raw defaultEmailAddress.emailAddress or defaultPhoneNumber.phoneNumber fields directly. They pull the Shopify payload, hand it to the helper, and get back a structured object with emailHash and phoneHash fields. That's what goes into the database.
The plaintext goes out of scope at function return. Never assigned to a long-lived variable, never serialized to logs, never written to a database column. It exists in memory for the duration of a single request and then is garbage collected.
The hashing function: pure, stdlib-only, no salt
The hashing utility itself (app/lib/identity/hash.ts) has a deliberately narrow design:
export function hashEmail(email: string | null | undefined): string | null {
if (!email || !email.trim()) return null;
const normalized = email.trim().toLowerCase();
return createHash("sha256").update(normalized).digest("hex");
}
export function hashPhone(phone: string | null | undefined, countryHint?: string): string | null {
if (!phone || !phone.trim()) return null;
const normalized = normalizePhone(phone, countryHint);
if (!normalized) return null;
return createHash("sha256").update(normalized).digest("hex");
}
A few deliberate properties.
Pure function, stdlib only. No logging, no external calls, no state. The input is the string, the output is the digest or null. Testable, auditable, cacheable without worrying about side effects.
Empty/whitespace input returns null, not a hash of empty string. hashEmail("") returns null, not e3b0c44298fc1c14... (the sha256 of empty string). Matters because if empty values hashed to the same digest, an entire customer population with missing emails would appear to be "the same customer" in correlation queries. Null preserves "we don't know this customer's email."
No salt. This is a conscious choice and the one that requires the most justification.
Salt (a per-record or per-user random string added before hashing) is the right choice for password storage, where you want each stored password hash to be unique even if the password is identical across users, and you never need to correlate hashes across users.
For fraud correlation, salt is exactly the wrong thing. We want two records with the same email to produce the same hash. That's the entire point. A salted hash breaks correlation.
What we give up by not salting: an attacker who steals the hash database and has a list of candidate emails (say, 100 million leaked email addresses from a breach elsewhere) can hash each candidate with SHA-256 and compare to our stored digests. Matches tell them which of those emails are in our customer base. This is real but bounded. It tells the attacker "this email is a customer," not "this email has this phone number and this address history." And the defense is strong perimeter security, not salted hashing.
Peppering (a secret constant added before hashing, shared across all records) is a middle ground we considered and rejected. It would defeat the leaked-email-list attack but would introduce a secret that, if leaked separately, would decrypt the entire fraud database. The operational cost of managing that secret correctly over time didn't outweigh the marginal protection.
Phone normalization. Before hashing, phone numbers are normalized to digits-only via .replace(/\D/g, ""). A country hint (US/CA/GB/AU/FR/DE/IT/ES/NL/BE/SE/NO) optionally prepends the country dial code. No libphonenumber dependency. Simple static map of country prefixes instead of a 2MB library for full E.164 parsing. The simple normalization catches 99% of duplicate-phone correlations. The edge cases (extension numbers, unusual country formats) are accepted as correlation gaps.
The database schema
In RefundSentry's CustomerProfile table (spec 107), the identity columns look like this:
model CustomerProfile {
id String @id @default(cuid())
shopId String
shopifyCustomerId String
emailHash String? // sha256, 64-char lowercase hex
phoneHash String? // sha256, 64-char lowercase hex
// ... other scoring fields, none of them raw PII
}
Four indexes:
[shopId, emailHash]: within-shop correlation (most common query)[shopId, phoneHash]: within-shop phone correlation[emailHash]: cross-shop email correlation (F1 queries for fraud rings spanning merchants)[phoneHash]: cross-shop phone correlation
The cross-shop indexes enable the fraud-ring detection use case. When a customer places an order at shop A and we want to know if the same email has been associated with flagged returns at shops B, C, or D, we query by the email hash. No plaintext ever leaves either shop's system.
Notably missing from the schema: email, emailAddress, phone, phoneNumber, displayName, fullName. The constitution forbids raw PII in general-purpose tables, and the schema enforces it. There's no column to accidentally write plaintext into.
Upgrade-only writes: null doesn't overwrite a hash
A subtle invariant: if a customer's record already has a populated emailHash, a subsequent update that arrives without an email should not null out the existing hash. Handles the case where a return webhook arrives with a partial Shopify payload that doesn't include the email (Shopify sometimes omits fields on specific webhook topics), and we don't want to lose the identity correlation we previously established.
Implementation pattern:
const updateData = {
shopifyCustomerId,
// ... other fields
...(emailHash != null ? { emailHash } : {}),
...(phoneHash != null ? { phoneHash } : {}),
};
If emailHash is null (the incoming payload didn't have an email), the property isn't included in the update at all, so the database column retains its existing value. Only a non-null hash overwrites a previous non-null hash (which handles the rare case of an email change).
The kind of invariant that's easy to get wrong and painful to debug later. You'd notice weeks or months after deploy that a chunk of your fraud correlations had silently degraded because webhook payloads with partial data were nulling out hashes. Integration tests specifically for this in tests/integration/webhooks/compliance-hash-erasure.test.ts and related files.
GDPR erasure: one row, cascade handles the rest
Because all of the fraud-correlation identity lives in the hash columns on CustomerProfile, and all of the scoring artifacts cascade from CustomerProfile via foreign keys, a GDPR erasure request resolves to a single DELETE on the CustomerProfile row. The cascade drops the hash columns along with every downstream row that references them.
The handleCustomersRedact webhook in app/routes/webhooks.compliance.tsx implements this. No custom erasure logic per table, no multi-system coordination. The database's referential integrity does the work, which is what it's designed for.
If we had plaintext PII sprinkled across fraud tables, scoring history, correlation graphs, and audit logs, the erasure would be a multi-system operation with real risk of incomplete coverage. With hash-only, it's a single delete.
Note: the erasure removes the customer from our fraud correlation entirely. After erasure, a subsequent order from the same customer (with the same email) will be treated as a brand-new customer. This is the GDPR-mandated outcome. You're not allowed to retain correlation state after erasure. And the architecture supports it cleanly.
What you give up, what you get
Give up:
- Substring search on emails/phones in fraud tables (fine, that's a CRM query, not a fraud query)
- "Show me all gmail.com customers" (same)
- Easy debugging of correlation results by eyeballing emails (you see hashes in the logs, useful data isn't directly readable by humans)
- The ability to "un-hash" when you want to send an email to a flagged customer (you'd need to pull the plaintext from the system-of-record, Shopify itself, at that moment, via a separate audited call)
Get:
- Database compromise exposes hashes, not PII
- Employee access to the fraud database doesn't expose customer contact info
- GDPR erasure is a single cascade delete
- Legal discovery produces hash digests, not customer lists
- The correlation semantics you need for fraud detection are identical
For a fraud tool specifically, where the business logic only needs exact-equality correlation, the trade is overwhelmingly in favor of hashing. The substring-search and human-readable-debugging losses are acceptable. The CRM use cases where you'd want those features belong in separate systems anyway.
The takeaway
A fraud detection system does not need to store plaintext email, phone, or address data. It needs stable digests that support equality comparison and it needs them derived at the edge, before the data persists anywhere in the general-purpose stack.
The architecture is simple. Hash at the edge via a pure stdlib-only function, index the digests, never write plaintext to scoring tables, use cascade deletes for GDPR erasure. The detection logic works identically. The blast radius of every class of incident (breach, employee access, legal discovery, regulatory erasure) shrinks materially.
If you're building or evaluating fraud tooling in 2026 and the vendor's database has an email column in their fraud tables, that tells you something about their engineering maturity and something about the incident you'll eventually be reading about. The architecture to do better exists and isn't complicated. It's a different default than the industry shipped with a decade ago, and the industry hasn't fully updated.