Mock Data Privacy & Anonymisation

This guide covers producing local fixtures that carry no personal data while still exercising the code paths real data would: field classification, the choice between masking, generalising, tokenising and synthesising, keeping joins intact across files, and gating the result in CI. It does not cover generating data from scratch — that is schema-driven data generation — nor the legal analysis of any specific jurisdiction.

Prerequisites

  • A schema or sample payload for every entity you intend to fixture (OpenAPI, JSON Schema, or a typed model)
  • Node.js 20+ with @faker-js/faker available for synthesis
  • A documented decision on where anonymisation runs — ideally inside the controlled environment, before data is exported
  • A secret salt held outside the repository (an environment variable or secret manager entry), never committed
  • Familiarity with deterministic seed management, because reproducible fixtures and reversible identifiers are different things

Classify before you transform

The single biggest mistake in this area is applying one transform to everything. Fields carry different kinds of risk, and the right treatment follows from the class, not from the field name.

Direct identifiers name a person on their own: email address, phone number, full name, national insurance or social security number, payment card number, account number. Any one of these identifies the record without help.

Quasi-identifiers identify nobody alone but combine into a fingerprint. Postcode, date of birth and gender together re-identify a large share of a population; add job title and employer and the share approaches certainty. This is the class that gets missed, because each field looks harmless in isolation.

Sensitive attributes are the values that must not leak even when the subject is unknown: health conditions, salary, political affiliation, sexual orientation, criminal records. Re-identification is not required for disclosure of these to be harmful.

Non-personal fields — order status, currency, timestamps, product SKUs, feature flags — carry no risk and should be left exactly as they are, because they are what makes the fixture realistic.

Field class determines the transform Four stacked rows. Direct identifiers are replaced with valid synthetic values. Quasi-identifiers are generalised into buckets such as a birth year or a postcode district. Sensitive attributes are resampled from a distribution so the marginal shape survives but no individual value is real. Non-personal fields are passed through untouched. Each row also names what goes wrong when that class is treated like the others. Direct identifier email, phone, full name, card number → synthesise a valid replacement still parses, still renders, still validates Star-masking instead breaks format checks downstream Quasi-identifier postcode, birth date, job title, employer → generalise into a bucket SW1A 1AA becomes SW1, 1987-04-12 becomes 1987 Leaving them intact re-identifies the row from combination Sensitive attribute diagnosis, salary, affiliation → resample from the distribution the marginal shape survives, the individual does not Copying through discloses even with no name Non-personal status, currency, SKU, timestamps → pass through untouched this is what keeps the fixture realistic Over-scrubbing produces a fixture that tests nothing

The rightmost column is the part teams underweight. Over-scrubbing is a real failure mode: a fixture where every string is "REDACTED" and every number is 0 passes any privacy review and exercises none of your rendering, sorting, formatting or validation code. The goal is a payload that is indistinguishable from production in shape and unrelated to it in content.

Phase 1 — a classification map

Encode the classification as data so it can be reviewed, diffed and enforced:

// src/fixtures/classification.ts
export type FieldClass = 'direct' | 'quasi' | 'sensitive' | 'open';

export const CLASSIFICATION: Record<string, FieldClass> = {
  // Customer
  'customer.id':            'direct',
  'customer.email':         'direct',
  'customer.phone':         'direct',
  'customer.fullName':      'direct',
  'customer.dateOfBirth':   'quasi',
  'customer.postcode':      'quasi',
  'customer.jobTitle':      'quasi',
  'customer.employer':      'quasi',
  'customer.annualIncome':  'sensitive',
  'customer.createdAt':     'open',
  'customer.locale':        'open',
  'customer.marketingOptIn':'open',
  // Order
  'order.id':               'direct',
  'order.customerId':       'direct',
  'order.status':           'open',
  'order.currency':         'open',
  'order.totalMinor':       'open',
  'order.placedAt':         'open',
};

/** Any field not in the map is unclassified — treat that as a build error. */
export function classify(path: string): FieldClass {
  const c = CLASSIFICATION[path];
  if (!c) throw new Error(`Unclassified field: ${path}. Add it to CLASSIFICATION.`);
  return c;
}

Throwing on unclassified fields is what keeps the map honest. When someone adds customer.nationalId to the schema, the fixture build fails until the field has been considered — which is far better than it silently flowing through as open.

Phase 2 — transforms per class, with joins preserved

The identifier transform is the interesting one, because a fixture set is useless if order.customerId no longer matches any customer.id. A keyed pseudonym solves both problems at once: deterministic, so joins survive; salted, so the original is not recoverable.

// src/fixtures/transforms.ts
import { createHmac } from 'node:crypto';
import { faker } from '@faker-js/faker';

const SALT = process.env.FIXTURE_SALT;
if (!SALT) throw new Error('FIXTURE_SALT is required and must never be committed.');

/** Same input → same output, but not reversible without the salt. */
export function pseudonym(prefix: string, original: string): string {
  const digest = createHmac('sha256', SALT).update(original).digest('hex');
  return `${prefix}_${digest.slice(0, 16)}`;
}

/** A valid, obviously-fake replacement that still passes format validation. */
export function syntheticEmail(original: string): string {
  const handle = pseudonym('u', original).slice(2, 12);
  return `${handle}@example.invalid`;
}

/** Widen a quasi-identifier until it describes a group, not a person. */
export function generalisePostcode(pc: string): string {
  return pc.trim().split(/\s+/)[0] ?? '';        // "SW1A 1AA" → "SW1A"
}

export function generaliseBirthDate(iso: string): string {
  return `${iso.slice(0, 4)}-01-01`;             // keep the year, drop the day
}

/** Resample a sensitive value from the same bucket, losing the individual. */
export function resampleIncome(actual: number): number {
  const band = Math.floor(actual / 10_000) * 10_000;
  return band + faker.number.int({ min: 0, max: 9_999 });
}

The .invalid top-level domain is reserved by the IANA specifically so it can never resolve. Using @example.com instead is a common and avoidable mistake: that domain is real, and a misconfigured test that actually sends mail will reach someone.

Applying the map to a record is then mechanical:

// src/fixtures/anonymise.ts
import { classify } from './classification';
import { pseudonym, syntheticEmail, generalisePostcode, generaliseBirthDate, resampleIncome } from './transforms';

export function anonymiseCustomer(row: Record<string, unknown>) {
  return {
    id:            pseudonym('cus', String(row.id)),
    email:         syntheticEmail(String(row.email)),
    phone:         `+44 7700 ${pseudonym('p', String(row.phone)).slice(-6)}`,
    fullName:      `${faker.person.firstName()} ${faker.person.lastName()}`,
    dateOfBirth:   generaliseBirthDate(String(row.dateOfBirth)),
    postcode:      generalisePostcode(String(row.postcode)),
    jobTitle:      faker.person.jobType(),
    employer:      faker.company.name(),
    annualIncome:  resampleIncome(Number(row.annualIncome)),
    // `open` fields pass straight through — this is what keeps it realistic
    createdAt:     row.createdAt,
    locale:        row.locale,
    marketingOptIn: row.marketingOptIn,
  };
}

Because pseudonym('cus', …) is deterministic, an order whose customerId runs through the same function still points at the right customer. The join graph survives intact, which is what makes these fixtures usable for the relational scenarios described in generating realistic relational mock data.

How a keyed pseudonym preserves the join On the left, a customer row with id cus_88213 and an order row referencing customerId cus_88213. Both pass through the same HMAC function keyed with the fixture salt. On the right, both emerge as cus_9f2a41d0c7b6e835, so the foreign key still resolves. A note records that without the salt the mapping cannot be reversed, and that rotating the salt per export prevents correlation between datasets. customers.json id: cus_88213 orders.json customerId: cus_88213 HMAC-SHA256 keyed with FIXTURE_SALT customers.fixture.json id: cus_9f2a41d0c7b6e835 orders.fixture.json customerId: cus_9f2a41d0c7b6e835 join intact Same input, same output — so foreign keys resolve. No salt, no reversal — so the original identifier is not recoverable from the fixture. Rotate the salt per export so identifiers from two different datasets cannot be correlated against each other.

Phase 3 — gate the output in CI

A classification map only helps if nothing bypasses it. A detector over the generated fixtures is the backstop, and it belongs in the pipeline rather than in a reviewer’s head:

// scripts/check-fixtures.ts
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

const PATTERNS: Array<[string, RegExp]> = [
  ['real email',        /[\w.+-]+@(?!example\.(invalid|test)\b)[\w-]+\.[a-z]{2,}/i],
  ['payment card',      /\b(?:\d[ -]*?){13,16}\b/],
  ['UK NI number',      /\b[A-CEGHJ-PR-TW-Z]{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?[A-D]\b/],
  ['US SSN',            /\b\d{3}-\d{2}-\d{4}\b/],
  ['E.164 phone',       /\+\d{1,3}\s?\d{4}\s?\d{6,}/],
  ['bearer token',      /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}/],
];

const dir = 'src/fixtures/generated';
let failures = 0;

for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
  const text = readFileSync(join(dir, file), 'utf8');
  for (const [label, re] of PATTERNS) {
    const hit = text.match(re);
    if (hit) {
      console.error(`${file}: possible ${label}${hit[0].slice(0, 24)}`);
      failures += 1;
    }
  }
}

if (failures) {
  console.error(`\ncheck-fixtures: ${failures} suspected identifier(s) in generated fixtures.`);
  process.exit(1);
}
console.log('check-fixtures: clean');

Note the negative lookahead on the email pattern — it deliberately allows the reserved example.invalid and example.test domains and flags everything else. A detector that flags your own synthetic values gets disabled within a week, so the exemptions have to be precise.

Where the fixture pipeline fails closed A left-to-right pipeline: schema, classification map, transform, generated fixture, detector scan, commit. Two stages are marked as failing closed — the classification lookup throws on any unclassified field, and the detector exits non-zero on any pattern resembling a real identifier. Both gates stop the pipeline before anything reaches the repository. Schema field list Classify throws if unmapped Transform per field class Detector scan exits non-zero on a hit Commit fixtures in the repo FAIL — "Unclassified field: …" FAIL — "possible real email" Both gates fail closed: a new schema field cannot flow through unconsidered, and a leaked identifier cannot reach the repository. The transform stage in the middle is the only place that needs the salt, so the secret has exactly one consumer. A pipeline that warns instead of failing is a pipeline that will eventually ship a real email address into a public repository.

Wire it into the same job that builds fixtures, so a fixture can never be committed without passing:

# .github/workflows/fixtures.yml
name: Fixtures
on: [push, pull_request]
jobs:
  build-and-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - name: Generate fixtures
        env:
          FIXTURE_SALT: ${{ secrets.FIXTURE_SALT }}
        run: npx tsx scripts/build-fixtures.ts
      - name: Scan for leaked identifiers
        run: npx tsx scripts/check-fixtures.ts

Verification steps

  • npx tsx scripts/build-fixtures.ts completes with no Unclassified field error — every schema field has been considered
  • npx tsx scripts/check-fixtures.ts prints check-fixtures: clean
  • grep -ric 'example\.com' src/fixtures/generated | grep -v ':0' returns nothing — synthetic addresses use a reserved domain
  • A join query across two generated files returns the same row count as the same query against the source shape
  • Regenerating with the same FIXTURE_SALT produces byte-identical output; regenerating with a different salt changes every identifier
  • The salt is absent from git log -p -- src/fixtures and from every committed .env

Troubleshooting

FIXTURE_SALT is required in local development. That error is doing its job. Add the salt to your local .env (which must be gitignored) and source it; do not add a default value in code, because a committed default is functionally the same as no salt at all.

Joins break after anonymisation. Almost always because two code paths pseudonymise the same identifier with different prefixes — pseudonym('cus', id) in one place and pseudonym('customer', id) in another produce different outputs from the same input. Centralise the prefix per entity as a constant rather than passing a literal at each call site.

The detector flags a synthetic phone number. Your generated numbers look real because the format is real. Use the ranges reserved for fiction — +44 7700 900000900999 in the UK, 555-0100555-0199 in North America — and add those exact ranges to the allowlist rather than loosening the pattern.

Fixtures regenerate differently on every run. Faker is unseeded. Set faker.seed(…) from a value derived from the record identifier so each record’s synthetic values are stable, exactly as deterministic seed management prescribes — otherwise every fixture rebuild produces a noisy diff and snapshot tests churn.

A reviewer asks whether this counts as anonymised or pseudonymised. If the salt still exists anywhere, it is pseudonymised, and pseudonymised data remains personal data under most regimes. Say so plainly in the pipeline’s documentation rather than claiming more than the technique delivers.

When to advance

You are done here when the fixture build fails on an unclassified field, when the detector runs in CI rather than on request, when no raw production export exists on any developer machine, and when a new engineer can regenerate the entire fixture set from the schema alone. The natural next step is to fold these fixtures into the scenario machinery so a privacy-safe dataset can also drive multi-step flows — see stateful scenario sequences.


Making the safe path the easy path

Every control described here fails eventually if the safe route is more work than the unsafe one. The durable version of this discipline is mostly about removing friction from the right path rather than adding it to the wrong one.

Make generated data good enough that nobody wants production data. Most requests to “just get a copy of a real record” are really requests for realistic data. A fixture set that includes the long names, the odd characters, the empty collections and the awkward states removes the motivation, which is far more effective than a policy telling people not to ask.

Make the scrub one command. If anonymising a payload requires reading documentation, finding a salt and assembling a pipeline, it will be skipped under time pressure. A single script, runnable inside the environment that holds the data, that reads a record identifier and writes a scrubbed file, is what makes the safe route the fast one.

Make the detector fast and precise. A scan that takes a minute and produces false positives gets disabled. One that runs in a second and only fires on genuine patterns gets kept, and its output is trusted.

Make the fixtures obviously fake. Reserved domains and reserved phone ranges do double duty: they cannot reach anyone, and they are recognisable at a glance, so a real value in a screenshot stands out immediately rather than blending in.

There is one control that cannot be made frictionless and should not be: the salt. It has to live outside the repository, and every consumer has to obtain it deliberately. That friction is the point — it is the mechanism that makes pseudonymisation meaningful, and removing it by committing a default value quietly removes the guarantee along with it.

FAQ

Is it ever acceptable to copy production data to a laptop?

Copying raw production records containing personal data onto developer machines is precisely the practice this area exists to eliminate. Copy the schema and the statistical shape instead, then generate locally. Where a specific production record is genuinely needed to reproduce a defect, anonymise it inside the controlled environment before it leaves — anonymising after it has already been copied does nothing about the copy.

Why is masking an email to a star string not enough?

Because it destroys the field’s usefulness while often failing to remove the risk. A masked value no longer validates, no longer renders, and no longer exercises the code under test, so the fixture becomes a worse test than it was. Meanwhile the neighbouring quasi-identifiers — postcode, birth date, job title — can still re-identify the record. Replace the address with a valid synthetic one and generalise the quasi-identifiers instead.

How do I keep foreign keys working after anonymisation?

Use a keyed pseudonym. Deriving the replacement from an HMAC of the original plus a secret salt means the same input always produces the same output, so joins survive, while the mapping cannot be reversed without the salt. Rotate the salt for each export so identifiers from two datasets cannot be correlated against one another.

Does anonymised data still count as personal data?

Pseudonymised data does — it remains regulated, because a mapping exists somewhere. Genuinely anonymised data, where re-identification is not reasonably possible by anyone, does not. That bar is higher than most pipelines clear, so treat fully generated fixtures as the default and reserve derived-from-production data for cases with a documented justification and a named owner.


← Back to Data Generation & Realism Strategies