Standard Operating Procedure — Data Protection

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.

Tool: Endrias Bridge v1.3.130+ · Mode: Full copy · Est. time: 15–30 minutes to write the rules

📚 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.

⚠
It fails closed. If a rule is invalid, the secret key is missing, or the run mode can't apply masking, the migration stops with a FATAL line and copies nothing. It never falls back to copying unmasked data.
SupportedNot 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

RuleWhat the target getsOptions and notes
redactREDACTED 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.
nullNULLThe column must be nullable.
partial123-45-6789 → XXX-XX-6789keep_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.
hashe.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.
emailu1ca0dd9b1c89@example.invalid"keep_domain": true keeps the real domain. Consistent like hash.
date_shiftThe date moved by up to ±max_daysEvery 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.

1 Choose the columns to mask

In the desktop app (v1.3.131+): on the Migrate tab, click Load Tables from Source, then Masking Rules….

  1. Pick a table on the left. Its columns are listed with their type and flags (PK, FK, unique, not null).
  2. 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 redact on a unique column) is refused right there, with the reason.
  3. Click Preview 5 rows to see real source values next to their masked versions. Nothing is written.
  4. No key yet? Click Generate key… (see Step 2).
  5. 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]"}
    }
  }
}
ℹ
To shift a patient's dates by the same amount in every table, use the same "by" person key everywhere. Above, Patient uses its primary key (PatientID) and Admission names PatientID explicitly.

2 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.

⚠
Keep the key out of the target environment. Anyone with the key and the masked data can test guesses: there are only about a billion possible SSNs, so a hashed SSN can be brute-forced with the key. Without the key it can't. Use the same key on every run if you need masked values to match across refreshes. Change it if you don't.

3 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.

4 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 containsFix
needs a secret keySet EB_MASKING_KEY (Step 2).
primary-key columns cannot be maskedPrimary 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 columnUse 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 NULLUse redact instead of null.
needs a text column / date/datetime columnThe rule doesn't fit the column type. See the rules table.
masking only runs in Full copy modeSwitch 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

AreaCurrent behavior
SpeedMasked 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.
ResumeMasked tables don't write PK-range checkpoints. After an interruption they resume using the streaming path's keyset/target check.
hash collisionsShape-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.
ConfigurationUse 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 encryptionNot offered. hash is one-way: masked values can't be decrypted back, even with the key.