Using production data in test safely: masking, synthetic data, and no-go cases
For developers deciding whether to copy production data into staging, QA, CI, or local environments. This guide shows where masked clones fit in the architecture, how to build a repeatable sanitization pipeline, when synthetic data is the better choice, and the cases where the right answer is simply no production data at all.
TL;DR — The default answer should be: do not put raw production data in test. If you need production realism, restore into an isolated environment, run a deterministic masking job before anyone or any app can touch it, rotate all secrets, and block all outbound integrations; if you cannot do all of that, use synthetic data instead. Reading time: ~7 min
What it is and where it sits
This is about feeding non-production environments with realistic data without leaking personal data, credentials, or live integrations. In practice you have three options:
- Masked production clone: restore a prod snapshot into an isolated DB, then irreversibly transform sensitive fields before app access.
- Synthetic data: generate records with the same shape and edge cases, but no real people or secrets.
- Say no: for some systems, neither is acceptable; test against fixtures, contract tests, or a tiny hand-curated dataset.
Architecture-wise, the masking pipeline sits between backup/replication and the test app. It replaces the dangerous pattern of "restore prod dump into staging and point the app at it".
View as text
[Prod App] ---- 1. writes ----> [Prod DB] [Prod DB] ---- 2. backup/snapshot ----> [Isolated Restore DB] [Mask Job] ---- 3. transform PII/secrets ----> [Sanitized DB] [Staging App] ---- 4. reads/writes ----> [Sanitized DB] [Sanitized DB] ---- 5. blocked egress ----> [SMTP/Payments/Webhooks]
What talks to it:
- Your backup tool or snapshot restore process (
pg_dump,pg_restore, managed snapshot restore, logical dump, replica promotion). - A masking job, usually SQL plus a small script layer.
- The test app, after masking completes.
- CI jobs, QA users, and developers.
What it replaces:
- Shared staging databases loaded from raw production dumps.
- Long-lived staging copies that quietly accumulate real customer data.
- Ad hoc "I just need one prod backup locally" workflows.
Where it lives in request/data flow:
- Not on the hot path of user requests.
- In the data supply chain for staging/QA/dev refreshes.
- Often implemented as a one-shot job in the same network as the restored DB, before app deployment or before opening access.
From a privacy angle, the key design rule is simple: the first readable state of the restored dataset must already be sanitized. If engineers can connect before masking finishes, you have already lost.
How it actually works
Walk one realistic example: a Rails or Node app on Postgres with tables users, orders, payment_methods, support_tickets, and an outbox table for webhooks.
Goal: refresh staging weekly from production while preserving relational integrity and enough realism for debugging search, pagination, and reporting.
Step 1: restore into an isolated database
Use a fresh database name, not the existing staging DB, so you can fail safely and cut over only after checks pass.
⚠️ Restoring into the wrong database can destroy test data or overwrite a shared environment. Run
echo $PGDATABASE $PGHOSTbeforedropdborpg_restore, and use a dedicated DB name likeapp_staging_refresh_2026_10_30.
createdb -h staging-db.internal -U app_admin app_staging_refresh_2026_10_30
pg_restore \
-h staging-db.internal \
-U app_admin \
-d app_staging_refresh_2026_10_30 \
--clean --if-exists --no-owner --no-privileges \
/backups/prod_2026_10_30.dump
Typical failure shape when the dump contains extensions or ownership you did not account for:
pg_restore: error: could not execute query: ERROR: role "prod_owner" does not exist
Command was: ALTER TABLE public.users OWNER TO prod_owner;
pg_restore: warning: errors ignored on restore: 12
Use --no-owner --no-privileges for non-prod restores unless you intentionally recreate prod roles.
Step 2: block all outbound side effects before app access
Do this before masking and before app startup:
- Point SMTP to a sink (
MAIL_HOST=mailhog,SMTP_HOST=127.0.0.1in dev, or disable email workers entirely). - Disable background workers that send webhooks/payments.
- Remove or replace API keys and signing secrets.
- Apply egress firewall rules or security groups so staging cannot reach payment gateways, production SMTP, or third-party APIs.
If your app has an outbox table or queued jobs, truncate them now.
TRUNCATE TABLE webhook_deliveries, email_outbox, payment_jobs RESTART IDENTITY;
Step 3: mask sensitive columns deterministically
Deterministic masking matters when the same source value appears in multiple places and joins/reporting must still work. Example: the same email in users.email and support_tickets.requester_email should become the same fake email.
A common pattern is HMAC or hash with a secret salt kept outside the DB dump. For staging, you do not need reversibility; in fact, you usually do not want it.
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- One-time helper schema for masking functions.
CREATE SCHEMA IF NOT EXISTS mask;
CREATE OR REPLACE FUNCTION mask.fake_email(src text, salt text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT 'user_' || substr(encode(digest(coalesce(src,'') || salt, 'sha256'), 'hex'), 1, 16) || '@example.test';
$$;
CREATE OR REPLACE FUNCTION mask.fake_phone(src text, salt text)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT '+1555' || substr(regexp_replace(encode(digest(coalesce(src,'') || salt, 'sha256'), 'hex'), '[^0-9]', '', 'g'), 1, 7);
$$;
Then apply updates in a transaction:
BEGIN;
UPDATE users
SET
email = mask.fake_email(email, current_setting('app.mask_salt')),
phone = mask.fake_phone(phone, current_setting('app.mask_salt')),
full_name = 'Test User ' || id,
password_hash = '$2b$12$J8oWlV...replace_with_known_test_hash...',
reset_token = NULL,
mfa_secret = NULL;
UPDATE support_tickets
SET requester_email = mask.fake_email(requester_email, current_setting('app.mask_salt'));
UPDATE payment_methods
SET
card_last4 = '4242',
card_token = NULL,
billing_address = jsonb_build_object('line1','123 Test St','city','Testville','country','US');
UPDATE api_credentials
SET
access_token = NULL,
refresh_token = NULL,
client_secret = NULL;
TRUNCATE TABLE audit_log_sensitive, oauth_grants, session_store;
COMMIT;
Why these choices:
- Emails/phones become consistent but fake.
- Passwords are reset to a known test credential so login flows can still be exercised.
- Tokens/secrets are nulled, not transformed. A masked live token is still dangerous if some downstream system accepts it.
- Sessions and grants are deleted because they are active credentials, not test data.
Step 4: validate that masking actually happened
Do not trust the script because it ran with exit code 0. Run assertions.
psql -h staging-db.internal -U app_admin -d app_staging_refresh_2026_10_30 -v ON_ERROR_STOP=1 <<'SQL'
SELECT COUNT(*) AS real_email_count
FROM users
WHERE email !~ '@example\.test$';
SELECT COUNT(*) AS non_null_tokens
FROM api_credentials
WHERE access_token IS NOT NULL OR refresh_token IS NOT NULL OR client_secret IS NOT NULL;
SQL
Expected output shape:
real_email_count
------------------
0
(1 row)
non_null_tokens
-----------------
0
(1 row)
If you see anything else, fail the pipeline and do not point the app at this DB.
Step 5: cut over staging and keep it ephemeral
Update the app to use the sanitized DB name, deploy, then delete the old staging copy after a short rollback window. The longer a test copy lives, the more likely it drifts, leaks, or gets used for things it should not.
When to use it (and when not to)
Use production-derived data only when the fidelity matters enough to justify the risk and operational overhead.
| Scenario | Recommendation |
|---|---|
| Query plans, pagination, reporting, search relevance depend on real row counts and skew | Use a masked production clone |
| You need edge-case coverage for names, addresses, Unicode, null patterns, weird legacy records | Start with synthetic data, then add a masked sample if gaps remain |
| CI for every pull request | Use synthetic fixtures or a small generated seed, not prod clones |
| Testing payments, healthcare, HR, minors' data, government IDs, or regulated records | Usually say no to production data in test |
| Vendor contract forbids non-prod copies or cross-border transfer | Say no |
| You cannot block outbound email/webhooks/API calls in staging | Say no |
| You only need to reproduce one bug in one account | Extract the minimum fields into a hand-built fixture; do not refresh all staging |
| Load testing | Usually synthetic data; prod clones distort behavior and increase exposure |
You probably do not need this if:
- Your app breaks mostly on schema changes, not data shape.
- The issue can be reproduced with 20 rows and one weird Unicode string.
- Your team cannot maintain a masking test suite with assertions.
- Staging is shared with vendors, contractors, or broad internal access.
Trade-offs
Every benefit has a bill.
- Benefit: realistic cardinality and distribution
- Cost: larger restores, slower refreshes, more storage, more DBA time.
- Benefit: catches bugs synthetic seeds miss
- Cost: masking logic becomes part of your production-adjacent system and must be versioned, reviewed, and tested.
- Benefit: preserves referential integrity and reporting behavior
- Cost: deterministic masking is harder than random replacement; cross-table consistency is easy to get wrong.
- Benefit: developers debug with familiar data shapes
- Cost: privacy risk increases with every extra person and tool that can access staging.
- Benefit: fewer “works in dev, fails on prod-sized data” surprises
- Cost: non-prod environments become expensive enough that teams keep them around too long.
- Benefit: faster than hand-curating edge cases
- Cost: lock-in to your current schema quirks; teams stop investing in good synthetic datasets and fixtures.
The biggest hidden cost is false confidence. A masked clone can still be unsafe if free-text fields contain names, emails, or secrets you did not enumerate. notes, description, payload, and JSON blobs are where masking programs go to die.
In practice
Example 1: a refresh script with hard failure on validation
#!/usr/bin/env bash
set -euo pipefail
export PGHOST=staging-db.internal
export PGUSER=app_admin
export DB_NAME="app_staging_refresh_$(date +%F)"
export PGPASSWORD="${PGPASSWORD:?set PGPASSWORD}"
export MASK_SALT="${MASK_SALT:?set MASK_SALT}"
createdb "$DB_NAME"
pg_restore -d "$DB_NAME" --clean --if-exists --no-owner --no-privileges /backups/prod_latest.dump
psql -d "$DB_NAME" -v ON_ERROR_STOP=1 <<SQL
SELECT set_config('app.mask_salt', '$MASK_SALT', false);
\i sql/mask.sql
\i sql/validate_mask.sql
SQL
echo "Sanitized database ready: $DB_NAME"
This restores a fresh DB, injects the masking salt via a session setting, runs masking SQL, then runs validation SQL. Gotcha: if mask.sql uses current_setting('app.mask_salt') before set_config, Postgres throws ERROR: unrecognized configuration parameter "app.mask_salt".
Example 2: application config that disables dangerous integrations in staging
# docker-compose.override.yml
services:
app:
environment:
APP_ENV: staging
SMTP_HOST: mailhog
SMTP_PORT: "1025"
STRIPE_SECRET_KEY: ""
WEBHOOKS_ENABLED: "false"
JOBS_PROCESS_EXTERNAL: "false"
extra_hosts:
- "api.stripe.com:127.0.0.1"
- "smtp.sendgrid.net:127.0.0.1"
mailhog:
image: mailhog/mailhog
ports:
- "8025:8025"
This reroutes email to a local sink and disables external job processing. Gotcha: blanking an API key is not enough if some code path falls back to a production secret from a mounted file or instance metadata; inspect the effective runtime env with env | sort inside the container.
Example 3: validation query for free-text leakage
-- validate_mask.sql
\set ON_ERROR_STOP on
DO $$
DECLARE
leaked_emails integer;
BEGIN
SELECT COUNT(*) INTO leaked_emails
FROM support_tickets
WHERE body ~* '[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}';
IF leaked_emails > 0 THEN
RAISE EXCEPTION 'Mask validation failed: % ticket bodies still contain email-like strings', leaked_emails;
END IF;
END $$;
This fails the refresh if free-text still contains email-like strings. Gotcha: regex checks catch obvious leaks, not all leaks; you still need table-by-table review of text and JSON columns.
Further reading
- PostgreSQL Documentation:
pg_dumpandpg_restore - PostgreSQL Documentation:
pgcrypto - OWASP Cheat Sheet Series: User Privacy Protection Cheat Sheet
- NIST Special Publication 800-122: Guide to Protecting the Confidentiality of PII
- The "Data protection by design and by default" guidance from your applicable regulator
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI