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.

Two scrubs, one of which still reproduces the bug A source payload has a 47-character company name, an array of 312 items and a null middle name. Naive redaction replaces the name with the word redacted, truncates the array to three items and deletes the null field, so none of the three triggers survive. Structure-preserving replacement swaps in a different 47-character string, generates 312 synthetic items and keeps the explicit null, so the fixture still reproduces the failure. Source payload company: 47 chars lineItems: 312 entries middleName: null reproduces the bug Naive redaction company: "REDACTED" lineItems: 3 entries middleName: removed reproduces nothing Structure-preserving company: 47 synthetic chars lineItems: 312 synthetic middleName: null (kept) still reproduces the bug The three properties that trigger the failure are all structural — length, cardinality and nullness — and all survive replacement. Redaction removes the personal data and the reproduction together, which is why it feels safe and is useless. A fixture that no longer fails is not a smaller version of the problem — it is a different fixture.

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.

What crosses the boundary A boundary line separates the controlled environment from developer machines and the repository. Inside the boundary sit the production datastore, the raw payload and the scrub script. Only the scrubbed fixture and its manifest cross the line. A crossed-out arrow shows the raw payload being downloaded first and scrubbed afterwards, which is the pattern to avoid because the raw data has already left the boundary. Controlled environment production store raw payload scrub runs here structure kept, values replaced the boundary Laptops and the repository scrubbed.json + manifest — allowed across raw payload downloaded then scrubbed — too late, it already crossed

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 notes or description field 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 Location headers, 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.


Before it goes into the repository Three questions with a defined answer required for each: which ticket justifies this payload existing, which structural property it preserves, and when it should be deleted. A payload with no answer to all three becomes permanent for no recorded reason. Why does it exist? the ticket it reproduces recorded in the manifest What property does it preserve? the length, count or null that triggers the bug asserted by a spec When does it go? when the fix ships and the spec is generalised or it is permanent by default Captured payloads accumulate; a deletion condition recorded on day one is the only thing that ever removes them.

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.

← Back to Mock Data Privacy & Anonymisation