Anonymising Production Payloads for Local Use
A customer hits a rendering bug that no generated fixture reproduces, and the only thing that would reproduce it is their actual payload — which contains their name, address and order history. This page covers extracting that payload safely: scrubbing at the source, preserving the structure that causes the failure, and proving both that it is clean and that it still fails.
Context: the value is in the structure, not the values
The reason a real payload reproduces a bug that a generated one does not is almost never the content. It is a 47-character company name that overflows a fixed-width cell, an array of 312 line items that trips a pagination assumption, a null where the types promised a string, or a nested structure four levels deeper than any fixture.
That is good news, because every one of those properties survives anonymisation. Replace the company name with a different 47-character string and the overflow still happens. Replace 312 real line items with 312 synthetic ones and the pagination still breaks.
It also tells you what a scrub must not do. Dropping a field, collapsing an array or replacing a long string with "REDACTED" destroys exactly the property you came for, and the resulting fixture reproduces nothing.
Solution
1. Run the transform inside the controlled environment
The scrub belongs where the data already is. A small script run through your support tooling, a bastion, or a job in the production account keeps the raw payload from ever reaching a laptop:
// ops/scrub-payload.ts — run INSIDE the environment that holds the data
import { createHmac } from 'node:crypto';
import { faker } from '@faker-js/faker';
const SALT = process.env.SCRUB_SALT;
if (!SALT) throw new Error('SCRUB_SALT is required.');
const pseudo = (v: string) => createHmac('sha256', SALT).update(v).digest('hex').slice(0, 16);
/** Replace a string with a synthetic one of exactly the same length. */
function sameLength(original: string, generator: () => string): string {
let out = '';
while (out.length < original.length) out += generator();
return out.slice(0, original.length);
}
const PERSONAL = new Set([
'email', 'phone', 'fullName', 'firstName', 'lastName', 'company',
'addressLine1', 'addressLine2', 'postcode', 'dateOfBirth', 'nationalId',
]);
export function scrub(node: unknown, key = ''): unknown {
if (node === null) return null; // keep explicit nulls — they matter
if (Array.isArray(node)) return node.map((v) => scrub(v, key)); // keep the length
if (typeof node === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = scrub(v, k);
}
return out;
}
if (typeof node === 'string' && PERSONAL.has(key)) {
if (key === 'email') return `${pseudo(node).slice(0, 10)}@example.invalid`;
if (key === 'dateOfBirth') return `${node.slice(0, 4)}-01-01`;
if (key === 'postcode') return node.split(/\s+/)[0];
// Everything else: same length, different content.
return sameLength(node, () => faker.lorem.word());
}
if (typeof node === 'string' && /^id$|Id$/.test(key)) return pseudo(node);
return node;
}
sameLength is the load-bearing helper. It is what keeps a 47-character company name 47 characters long, so the layout bug you are chasing survives into the fixture.
Keeping null rather than dropping the key is the second one. A null where the client expects a string is one of the most common production-only crashes, and a scrub that removes the key removes the crash.
2. Emit the fixture and a manifest together
// ops/scrub-payload.ts (continued)
import { writeFileSync } from 'node:fs';
const raw = JSON.parse(process.argv[2] ?? '{}');
const clean = scrub(raw);
writeFileSync('scrubbed.json', JSON.stringify(clean, null, 2) + '\n');
writeFileSync(
'scrubbed.manifest.json',
JSON.stringify(
{
// Deliberately NOT the original identifier — the manifest must be safe too.
sourceRef: pseudo(String(raw.id ?? 'unknown')),
scrubbedAt: new Date().toISOString(),
scrubberVersion: '1.2.0',
fieldsReplaced: [...PERSONAL],
ticket: process.env.SUPPORT_TICKET ?? 'unspecified',
},
null,
2
) + '\n'
);
The manifest answers, months later, what this file is and why it exists — a question that otherwise gets answered with “nobody knows, leave it”.
3. Prove it is clean, then prove it still fails
Two checks, in that order:
# 1. Nothing recognisable survived
npx tsx scripts/check-fixtures.ts src/fixtures/support/scrubbed.json
# 2. The bug still reproduces against the scrubbed payload
npx vitest run src/features/orders/OrderSummary.test.tsx -t 'long company name'
If the second command passes, the scrub destroyed the reproduction and the fixture is worthless — go back and find which structural property was flattened. If the first fails, something personal survived and the file must not be committed under any circumstances.
Verification
# The file is structurally what you expect
jq '{ company: (.company | length), items: (.lineItems | length), middle: .middleName }' \
src/fixtures/support/scrubbed.json
# Nothing personal survived
npx tsx scripts/check-fixtures.ts src/fixtures/support/
# The manifest exists and names a ticket
jq -e '.ticket != "unspecified"' src/fixtures/support/scrubbed.manifest.json
The first command is the quickest structural sanity check — the lengths and counts should match what you recorded from the original, and a company length of 8 where you expected 47 tells you the scrub flattened it.
Gotchas and edge cases
-
Free-text fields carry personal data the field name does not advertise. A
notesordescriptionfield routinely contains a name, a phone number or an address typed by a support agent. Name-based rules never catch these. Replace free-text fields wholesale with generated text of the same length rather than trying to detect what is inside them. -
Identifiers appear in more places than the id field. Customer references turn up embedded in URLs, in
Locationheaders, in error messages and in audit trails inside the same payload. Pseudonymise by value across the whole document, not only where the key looks like an identifier, or the original leaks through a field you did not think to classify. -
One scrubbed payload is not a fixture set. It reproduces one defect and should be scoped to the test that needs it, with the ticket recorded in the manifest. Building a whole suite on captured payloads reintroduces the drift problem that schema-driven data generation exists to solve.
Preferring a generalised fixture to a captured one
A scrubbed production payload is a legitimate tool and should be a last resort. Where the same defect can be reproduced by a hand-written record, the hand-written record is better on every axis that matters.
It is reviewable. A record whose long name is obviously deliberate tells the next reader what it is protecting. A captured payload with hundreds of fields does not, and its purpose is lost as soon as whoever captured it moves on.
It is minimal. A capture contains everything the real record contained, most of which is irrelevant to the bug. That noise makes the fixture harder to reason about and larger to diff.
It carries no obligation. A generated record has no privacy status to track, no salt to manage and no deletion condition to remember.
It generalises. A hand-written record can be written to be slightly worse than the real one — a longer name, one more line item — so it also catches the next bug of the same kind rather than only this one.
The practical workflow that follows: capture the payload, use it to find the property that reproduces the failure, and then write a minimal fixture that has that property. Once the minimal fixture reproduces the bug, delete the capture. That sequence gets the diagnostic value of real data without keeping any of it.
The cases where a capture genuinely has to stay are the ones where the reproducing property could not be identified — a combination of fields, an encoding subtlety, a structure nobody could describe. Those exist, and they are rarer than the number of captured payloads in most repositories would suggest.
Related
- Generating GDPR-Safe Customer Fixtures — building a whole dataset without touching production at all
- Mock Data Privacy & Anonymisation — the classification and detector this scrub relies on
- Recording and Replaying Real API Traffic — the same scrubbing discipline applied to whole sessions
← Back to Mock Data Privacy & Anonymisation