Salesforce field history tracking logs every change to a tracked field, storing both the old value and the new value in a dedicated history table. If a tracked field ever held a real phone number, email address, or national ID, that value sits in the history record indefinitely, completely outside the reach of a masking job that only updates the current row. Most masking tools never look at these tables, which means a sandbox can look fully masked on the surface while leaking real customer data two clicks away in a related list.
This is not a theoretical edge case. Any org running Salesforce for more than a year or two has field history tracking enabled on at least a few objects, usually Account, Contact, and Opportunity. Every edit to a tracked field since the day it was turned on created a permanent row of old and new values. Masking the current record does nothing to those rows.
What Field History Tracking Actually Records
Admins can enable tracking on up to 20 fields per object through Object Manager. Once enabled, Salesforce writes a new row to an object-specific history table every time one of those fields changes: the old value, the new value, who made the change, and when. Standard objects use tables like ContactHistory and OpportunityFieldHistory. Custom objects get a table named after the object with __History appended.
Say Contact.Phone is tracked. A support rep updates a customer's number after a house move. Salesforce quietly writes a row containing the prior phone number, the new one, the user who made the edit, and a timestamp. Multiply that by every tracked field, every edit, every year the org has existed, and you end up with a history table that can hold more raw PII than the object it is attached to.
Why Masking Tools Walk Right Past It
Most masking engines work by running update statements against an object's current records, driven by a field-mapping list the admin builds during setup. History tables rarely appear on that list because they are not treated like normal custom or standard objects in day-to-day admin work. Nobody builds reports off ContactHistory. Nobody puts it on a page layout. It is invisible until someone goes looking for it, which is exactly why it gets left out of masking scope.
In my view, this is the single most underrated gap in sandbox data protection. It doesn't show up in a smoke test. A QA engineer clicking through a Contact record after masking sees a clean, fake phone number on the page. Nobody thinks to open the field history related list and check what the previous three values were. The exposure sits there, quiet and fully queryable, until an audit or a careless export surfaces it.
The Compliance Exposure This Creates
Under GDPR, pseudonymizing a field's current value does not satisfy the obligation if the real value is still retrievable elsewhere in the same system. Field history tables are exactly that: a retrievable, timestamped record of real personal data sitting inside a lower-security sandbox environment. Anyone with read access to the object, including most developers and QA staff in a full sandbox, can pull that history through a simple SOQL query or the standard related list.
Consider a common scenario. A sandbox refresh masks the Email field on every Contact, but the history table for that object still shows the last five real email addresses a customer used before each change. A developer debugging an email-deliverability bug queries ContactHistory directly, because that is the fastest way to see what changed and when. They now have live customer email addresses in a non-production environment, not because anyone did anything wrong, but because the masking job never touched the table where that data actually lives.
Finding Out How Exposed You Are
Start by pulling the list of fields with history tracking enabled from Object Manager, for every object that matters: standard objects first, then any custom object used for customer, employee, or financial data. Cross-reference that list against your existing masking field map. Any tracked field that also appears on your masking map but whose history table is not part of your masking job scope is an open gap.
Run a direct query against the relevant history object, something like a SOQL pull of OldValue and NewValue from ContactHistory, and look at what comes back. If real names, emails, or phone numbers appear in that output inside a sandbox, you have confirmed the exposure. Orgs running Field Audit Trail, part of Salesforce Shield, should pay particular attention here, since Field Audit Trail extends retention of this history data up to ten years and archives older records into big objects. Years of real PII, not months, can be sitting in that archive.
Closing the Gap Before Refresh
Treating history tables as in-scope for masking requires two things: knowing which fields are sensitive, and extending the masking job to overwrite OldValue and NewValue in the corresponding history table wherever that field appears. This is more than a cosmetic fix. It means the masking logic needs a second pass that targets ContactHistory, AccountHistory, OpportunityFieldHistory, and any custom __History tables, not just the parent objects.
MaskEzee builds this into the masking run itself rather than treating it as an afterthought. When a field is flagged as sensitive in the masking configuration, the job checks whether that field has tracking enabled and, if so, applies the same masking logic to the historical old and new values before the sandbox is handed over. For orgs on Field Audit Trail, this extends to the archived big object records as well, so a decade of tracked changes does not become the one place real data survives a refresh.
Some teams choose a simpler route: purge history rows for sensitive fields entirely before refresh rather than masking them in place. That works, but it removes audit context that developers sometimes genuinely need when debugging data issues in a sandbox. Masking the history values while preserving the change pattern, who changed what and when, tends to be the better tradeoff for most orgs.
A Practical Checklist for Admins
Before your next sandbox refresh, run through these steps rather than assuming your existing masking job already covers this:
- Pull the full list of history-tracked fields from Object Manager across every relevant object, standard and custom.
- Cross-check tracked fields against your masking field map to flag any gaps.
- Query the corresponding history tables directly to confirm whether real values are present.
- Check whether Field Audit Trail is enabled and account for archived big object history separately.
- Add history table masking as an explicit step in your refresh runbook, not an assumed side effect of masking the parent object.
Field history tracking exists to give admins and auditors a change trail, and that is a genuinely useful feature. The problem is not the feature itself, it is treating it as outside the perimeter of a masking job when it holds the exact same personal data the masking job was built to protect. Closing this gap takes one extra pass through your configuration, and it is a far cheaper fix than explaining to an auditor why a sandbox still contained three years of real customer phone numbers in a table nobody thought to check.
Frequently Asked Questions
Does Salesforce field history tracking automatically include PII?
Yes, if a tracked field ever held personal data, such as Contact.Phone or a custom SSN field, the history table stores the real old and new values every time that field changes. Field history tracking does not distinguish between sensitive and non-sensitive fields; it simply logs whatever was there before and after the edit. This makes it a likely home for real PII in any org that has tracked customer-facing fields for more than a few months.
Can I just disable field history tracking before refreshing a sandbox?
You can disable tracking going forward, but that does not remove history rows already created before you turned it off. Existing OldValue and NewValue entries stay in the history table unless they are explicitly purged or masked. Disabling tracking also removes a legitimate audit capability that developers and admins may rely on for debugging, so it is usually better to mask the historical values than to switch tracking off entirely.
How far back does Field Audit Trail data go, and why does that matter for masking?
Field Audit Trail, part of Salesforce Shield, can retain field history data for up to ten years, archiving older records into big objects once they age out of the standard history table. That means a sandbox refresh without proper masking scope can expose a decade of real field changes rather than just the recent months covered by standard history retention. Any masking plan needs to account for this archived data separately, since it lives outside the normal history object structure.
What is the API name pattern for Salesforce field history tables?
Standard objects typically follow a predictable pattern, such as ContactHistory, AccountHistory, and OpportunityFieldHistory. Custom objects use the object's API name with __History appended, for example Invoice__c becomes Invoice__History. Knowing these naming patterns is the first step to querying and auditing what PII sits inside them before a refresh.
Does MaskEzee mask Salesforce field history tables automatically?
Yes, MaskEzee extends masking logic to the history table whenever a sensitive field has tracking enabled, overwriting both the old and new stored values rather than leaving them untouched. For orgs running Field Audit Trail, this also covers archived big object history, so older tracked changes get the same treatment. The goal is to make sure the masking job covers every place a sensitive value is stored, not just the current record.