If you clone a live WordPress site to test something, the copy contains real customer data from the moment it finishes restoring. The safest fix is not to copy that data at all, and when you must, to scrub it before anyone other than you can reach the site.
Most guides on this subject anonymize the users table and stop. That is the smallest part of the problem.
What actually comes across when you clone a site
A clone is a database dump plus wp-content. Personal data hides in four different places, and only the first one has tooling written for it.
The user records. wp_users and wp_usermeta, plus WooCommerce billing and shipping meta. If the store runs High-Performance Order Storage, the orders are in wp_wc_orders, wp_wc_order_addresses and wp_wc_orders_meta instead of wp_posts and wp_postmeta, so a scrub written for the classic layout silently misses every order.
The secrets in wp_options. Payment gateway keys, SMTP passwords, CRM and analytics tokens, licence keys, webhook signing secrets. These are not personal data, but a leaked live Stripe key is a worse afternoon than a leaked email address.
The uploads folder. Generated PDF invoices, form-plugin file uploads, CSV exports someone left in wp-content/uploads/, and any migration or backup archive parked in the web root. These are usually served without an access check.
The things the site does on its own. Cron fires, an abandoned-cart sequence runs, a plugin re-syncs to a live CRM, a test order sends a real receipt to a real person. Data does not have to be read to leak. The copy can push it out by itself.
Why the users table gets all the attention
Because that is where the tooling is. The existing options are genuinely useful and worth knowing:
- The anonymization addon for WP Migrate replaces user data with Faker-generated values during an export, pull or push. It covers WordPress core and WooCommerce user and usermeta tables, with filters to extend it.
- The Database Anonymization plugin is rule-based, works across any table with a single-column primary key, and understands serialized and JSON values. It also lets you skip your own admin record so you do not lock yourself out.
- A short PHP script that randomizes names, logins and emails. Its own author notes it leaves other user meta, such as addresses, untouched.
None of them touch wp_options, the uploads folder, or outbound email. Sucuri's recent staging guide is the one that names the wider risk, listing customer records, database credentials and API keys among the things a clone copies. It still leaves you to work out the order of operations.
The order matters more than the tool
Anonymizing after the copy is publicly reachable is closing a door that was already open. The sequence that actually holds is:
- Create the copy somewhere private and short-lived by default.
- Confirm it cannot send email or call a live third-party service.
- Scrub the data.
- Only then share the URL with anyone.
Steps 1 and 2 are environment properties, not something you can bolt on with a plugin. That is the argument for testing on a disposable site rather than a long-lived staging subdomain: a staging site outlives the reason it was created, and nobody remembers to delete it.
On SandyWP, a sandbox created by an account defaults to a one-week lifespan and can be set anywhere from one hour to one month. When it expires it stops running, and after the retention window the container, database, files and route are removed. An import restored by Cloner is finalized with blog_public set to 0 and core auto-updates disabled, so it is not asking to be indexed.
Email is the part people miss. With no SMTP configured, a SandyWP sandbox intercepts wp_mail() and writes the message to the Email Log instead of delivering it. You can read the exact rendered receipt without a real inbox receiving it. Add your own SMTP plugin and it sends for real again, which is the correct behavior and also the thing to leave alone on a copy of a live store.
A scrub that covers more than the users table
Open a shell with SSH or run these through the CLI, and check each one against your own plugin stack rather than trusting the list.
Find the secrets first, because this is the step nobody writes down:
wp option list --search='*_key*' --format=table
wp option list --search='*_secret*' --format=table
wp option list --search='*_token*' --format=table
wp option list --search='*smtp*' --format=table
Read the output before deleting anything. Some plugins store a whole settings array in one option, so you want wp option get <name> --format=json and a targeted update, not a blanket delete that breaks the plugin you came here to test.
Then the user records. example.invalid is reserved by RFC 2606, so nothing can ever be delivered to it:
wp db query "UPDATE wp_users SET user_email = CONCAT('user', ID, '@example.invalid'), display_name = CONCAT('User ', ID) WHERE ID <> 1;"
wp user meta delete <id> billing_address_1
wp transient delete --all
wp cache flush
Then the parts that are not in the database at all:
ls -la wp-content/uploads/*.csv wp-content/uploads/*.sql 2>/dev/null
find wp-content/uploads -name '*.pdf' | head
Delete what you find unless the test needs it. An invoice PDF is customer data with a predictable URL.
Verify by searching for something you know is real, such as a customer's surname, rather than by looking at the admin user list:
wp db search "Wozniak" --all-tables
If that returns rows you did not expect, your scrub was incomplete, and that is exactly the outcome you want to discover before the link goes anywhere.
The better answer: do not copy the data
Most tests do not need real customers. They need a site that is shaped like the real one.
If you are testing a plugin update, a theme change, a PHP version bump or a WordPress release, seed a site from a Blueprint or a Template with generated data and the same plugin set. There is nothing to scrub because there was never anything to leak, and the environment is reproducible for the next person who has to check the same thing.
Copy production data only when the bug depends on it: a specific order that will not refund, a taxonomy tree that only exists on the live site, a data shape you cannot invent. Then treat that sandbox as production data for as long as it exists, which means short expiry, no shared link, and delete it when the ticket closes. This is the routine that scales for agencies and support teams working across client sites.
Where this approach does not help
It is not a compliance opinion. Whether you may process a copy of customer data for a given purpose is a question for whoever owns that decision at your organization. This post is about the technical exposure, not the legal basis.
Scrubbing changes the data you were testing. Randomize the user table and you can no longer reproduce a bug that depends on a specific customer record. Sometimes the honest answer is a real copy with a one-hour lifespan rather than an anonymized copy that no longer exhibits the problem.
A sandbox is not your host. It will not reproduce object caching, a WAF, open_basedir, or your production cron behavior. If the bug is in the hosting layer, this is the wrong tool.
SandyWP does not anonymize anything for you. There is no scrub button. What it gives you is a copy that is private, short-lived, not indexed, and cannot email a real customer by accident, which is the part a staging subdomain gets wrong.
The single change that removes most of the risk is not a better anonymization script. It is making the copy expire.
