Data Masking During Migration
How to copy a production database into a dev, test or analytics environment with sensitive columns masked on the way: SSNs, emails, names, birth dates, free-text notes. The raw values never reach the target.
Overview & Scope
You list the columns to mask, and the rule for each one, in a JSON file. When a full-copy migration starts, Endrias Bridge checks every rule against the real table definitions before it touches the target. Then it masks each row in memory before writing it.
FATAL line and copies
nothing. It never falls back to copying unmasked data.| Supported | Not yet supported |
|---|---|
Full copy between relational engines (SQL Server / Azure SQL, PostgreSQL, MySQL / MariaDB, Oracle, SQLite) from the desktop app, the CLI migrate command and the REST API |
Incremental, CDC, CDC stream and CT sync modes; MongoDB / NoSQL / Snowflake / lakehouse targets. These runs are refused while a rules file exists. |
The Rules
| Rule | What the target gets | Options and notes |
|---|---|---|
redact | REDACTED for text (cut to the column length), 0 for numbers, 1970-01-01 for dates, false for booleans | "value" sets your own text. Not allowed on unique columns. |
null | NULL | The column must be nullable. |
partial | 123-45-6789 → XXX-XX-6789 | keep_last (default 4), keep_first (default 0), mask_char (default X). Separators stay. A value too short to keep anything is fully masked. Text columns only. |
hash | e.g. Smith → Qdwlr, 123-45-6789 → 804-17-2290 (the actual output depends on your key) | Keyed HMAC-SHA256. The same input gives the same output in every table and every run, so joins on the masked value still line up. Keeps the shape (digit→digit, letter→letter, same length) unless "format": "hex". Integer columns get an integer in the column's range. The only rule allowed on foreign-key columns. |
email | u1ca0dd9b1c89@example.invalid | "keep_domain": true keeps the real domain. Consistent like hash. |
date_shift | The date moved by up to ±max_days | Every date of the same person moves by the same number of days, so intervals (age at admission, length of stay) stay correct. The person is identified by "by", which defaults to the table's primary key. max_days defaults to 30. |
Under every rule, NULL stays NULL.
Choose the columns to mask
In the desktop app (v1.3.131+): on the Migrate tab, click Load Tables from Source, then Masking Rules….
- Pick a table on the left. Its columns are listed with their type and flags
(
PK,FK,unique,not null). - Pick a column, choose a rule (plus its options), and click Apply to column.
The dialog runs the same safety checks as the migration, so a rule that can't work
(for example
redacton a unique column) is refused right there, with the reason. - Click Preview 5 rows to see real source values next to their masked versions. Nothing is written.
- No key yet? Click Generate key… (see Step 2).
- Click Save Rules.
Or write the file by hand. Save it as %APPDATA%\EndriasBridge\masking_rules.json, or anywhere you like and
point masking_rules_file in migrationbridge.conf at it. Table names
can be schema.table or just table. Column and table names are not
case-sensitive.
{
"tables": {
"dbo.Patient": {
"SSN": {"rule": "partial", "keep_last": 4},
"Email": {"rule": "email"},
"LastName": {"rule": "hash"},
"BirthDate": {"rule": "date_shift", "max_days": 30},
"Notes": {"rule": "redact"},
"HomePhone": {"rule": "null"}
},
"dbo.Admission": {
"AdmitDate": {"rule": "date_shift", "max_days": 30, "by": "PatientID"},
"Diagnosis": {"rule": "redact", "value": "[removed]"}
}
}
}
"by" person key everywhere. Above, Patient uses its primary key
(PatientID) and Admission names PatientID explicitly.Set the secret key
hash, email and date_shift need a secret key. Without it
they refuse to run. Generate a long random value once and keep it in your password vault:
python -c "import secrets; print(secrets.token_urlsafe(32))"
In the Masking Rules dialog, Generate key… creates one, saves it as masking_key in your migrationbridge.conf and copies it to the clipboard. Otherwise provide it as the EB_MASKING_KEY environment variable (preferred), or as
masking_key in migrationbridge.conf.
Run a Full copy and check the log
Choose Full sync mode and start the migration as usual. Each masked table shows one line in the migration log:
[Patient] MASKING SSN: partial, Email: email, LastName: hash, BirthDate: date_shift, Notes: redact, HomePhone: null — streaming path (bulk-load paths and PK-range checkpoints skipped)
From the command line:
set EB_MASKING_KEY=<your key>
migrationbridge migrate --src-type "SQL Server (VM / On-Prem)" --src-host prod-sql --src-db Clinic ^
--src-user reader --src-pass *** --tgt-type "Azure SQL Database" --tgt-host dev.database.windows.net ^
--tgt-db Clinic_Dev --tgt-user loader --tgt-pass *** --mode full --verify
If the rules file names a table that isn't being copied, the log shows
WARNING (masking): ... matched none of the tables. Check that the names match.
Verify
Post-migration verification compares row counts, and checksums of sampled rows, leaving the masked columns out, since those differ from the source by design. Then spot-check the target yourself:
SELECT TOP 5 PatientID, SSN, Email, LastName, BirthDate FROM dbo.Patient;
-- expect XXX-XX-1234, u…@example.invalid, shaped-but-wrong names, dates within ±30 days
The Compare tool's row-level diff doesn't know about masking yet, so it reports masked columns as differences.
Why a Run Was Refused
| FATAL message contains | Fix |
|---|---|
needs a secret key | Set EB_MASKING_KEY (Step 2). |
primary-key columns cannot be masked | Primary keys drive resume and duplicate detection. Leave them out, or mask a copy of the value in a non-key column. |
would create duplicates in a unique column | Use hash, which keeps distinct values distinct. |
foreign-key column — only "hash" | Use hash on the foreign-key column and on the column it references, so the references still match. |
column is NOT NULL | Use redact instead of null. |
needs a text column / date/datetime column | The rule doesn't fit the column type. See the rules table. |
masking only runs in Full copy mode | Switch to Full, or move the rules file aside to run that mode unmasked. |
not supported for MongoDB ... | NoSQL routes can't mask yet. Move the rules file aside to run unmasked. |
Known Limitations
| Area | Current behavior |
|---|---|
| Speed | Masked tables use the row-streaming path instead of bcp / PS-BulkCopy / Postgres COPY / MySQL LOAD DATA, so expect them to copy slower. Unmasked tables in the same run are unaffected. |
| Resume | Masked tables don't write PK-range checkpoints. After an interruption they resume using the streaming path's keyset/target check. |
hash collisions | Shape-preserving hashing of short values (e.g. 4-digit codes) can map two inputs to the same output. Use "format": "hex", or don't hash short unique codes. |
| Configuration | Use the Migrate tab's Masking Rules… dialog or edit the JSON file. The headless API and CLI read the same file; there is no per-request rules field yet. |
| Format-preserving encryption | Not offered. hash is one-way: masked values can't be decrypted back, even with the key. |