Data Engineering · lesson 18 of 23
Beginner
8- 1 Data Engineering Fundamentals
- 2 File Formats: CSV, JSON, Parquet
- 3 Idempotency and Safe Retries
- 4 Batch Pipelines and Time Windows
- 5 Partitioning and Physical Layout
- 6 Batch vs. Streaming: The Real Trade-off
- 7 Data Lake vs. Data Warehouse vs. Lakehouse
- 8 Data Modeling Basics: Star Schemas and Denormalization
Intermediate
4Advanced
11- 13 Streaming: Exactly-Once Semantics
- 14 Schema Evolution and Data Contracts
- 15 Streaming Architecture: Lambda and Kappa
- 16 Data Lineage: Tracing a Column Back to Its Source
- 17 Data Governance: Catalogs, Classification, and Access Policy
- 18 Data Security and PII: Hashing, Masking, and Tokenization
- 19 Pipeline Observability: Tracing, Anomaly Detection, and Alerting
- 20 Cost Optimization: Measuring What Partition and Column Pruning Actually Save
- 21 CI/CD for Data: Gating Promotion with Real Tests
- 22 Disaster Recovery: RPO, RTO, and What a Backup Actually Loses
- 23 Data Platform Architecture: Wiring the Pieces Together
Also part of Data Engineering
9- + Your First Airflow DAG from Airflow
- + Kafka Topics, Partitions, and Offsets from Kafka
- + Spark Architecture: Driver, Executors, and Cluster Managers from Apache Spark
- + Models, ref, and the DAG from dbt
- + Delta Lake: The Transaction Log from Databricks
- + The Medallion Architecture from Databricks
- + File I/O, pathlib, and JSON in Python from Python
- + Error Handling in Python from Python
- + Terraform Fundamentals (IaC) from Terraform
Also part of: Security Fundamentals Cryptography
Data Security and PII: Hashing, Masking, and Tokenization
Run the same email and SSN through three PII techniques and see what each gives up: hashing for joins, masking for display, tokenization for reversal.
By SkillByExample · Published
Run the same email and SSN through three different techniques, and see exactly what each one gives up in exchange for its guarantee.
Two customers, two sensitive fields each
import hashlib, secrets
customers = [
{"customer_id": 1, "email": "ada@example.com", "ssn": "123-45-6789", "card": "4111111111111111"},
{"customer_id": 2, "email": "grace@example.com", "ssn": "987-65-4321", "card": "5500000000000004"},
]
Hashing: deterministic, one-way, safe to join on
def hash_pii(value, salt="platform-salt-2026"):
return hashlib.sha256((salt + value).encode()).hexdigest()[:16]
for c in customers:
h = hash_pii(c["email"])
print(f" {c['email']:<20} -> {h}")
h_again = hash_pii(customers[0]["email"])
print(f" same input hashed again: {h_again} (matches: {h_again == hash_pii(customers[0]['email'])})")
$ python security_pii.py
== hashing: deterministic, one-way, safe to join on ==
ada@example.com -> 5478b2ea4f25a8c3
grace@example.com -> 5cfdb214d49823d9
same input hashed again: 5478b2ea4f25a8c3 (matches: True)
Same email hashed twice gives the same fingerprint — that determinism is exactly what makes
hashed PII usable for joins and deduplication across tables without either table ever storing
the real email. There is no unhash() function; the original value is gone once hashed.
Masking: format-preserving, irreversible, safe to display
def mask_email(email):
local, _, domain = email.partition("@")
return f"{local[0]}{'*' * (len(local) - 1)}@{domain}"
def mask_card(card):
return f"{'*' * (len(card) - 4)}{card[-4:]}"
for c in customers:
print(f" email {c['email']:<20} -> {mask_email(c['email'])}")
print(f" card {c['card']:<20} -> {mask_card(c['card'])}")
== masking: format-preserving, irreversible, safe to display ==
email ada@example.com -> a**@example.com
card 4111111111111111 -> ************1111
email grace@example.com -> g****@example.com
card 5500000000000004 -> ************0004
Masking keeps enough shape to be useful in a support UI — “is this the card ending in 1111?” — without exposing the full value, and without even needing a lookup: the masked form is derived directly from the input every time, nothing extra to store or protect.
Tokenization: reversible, but only through the vault
VAULT = {}
def tokenize(value):
token = f"tok_{secrets.token_hex(6)}"
VAULT[token] = value
return token
def detokenize(token, requester_role):
if requester_role != "compliance_officer":
raise PermissionError(f"role '{requester_role}' cannot detokenize")
return VAULT[token]
token = tokenize(customers[0]["ssn"])
print(f" ssn {customers[0]['ssn']} -> token {token}")
try:
detokenize(token, "support_agent")
except PermissionError as e:
print(f" DENIED: {e}")
print(f" compliance_officer detokenizes: {detokenize(token, 'compliance_officer')}")
== tokenization: reversible, only for an authorized role ==
ssn 123-45-6789 -> token tok_6a358ce01552
support_agent tries to detokenize:
DENIED: role 'support_agent' cannot detokenize
compliance_officer detokenizes: 123-45-6789
secrets.token_hex generates a fresh random token every run, so the exact token string above
will differ if this runs again — that unpredictability is the point, it carries zero
information about the SSN it stands in for. The token can travel freely through logs,
downstream tables, even third-party systems, because reversing it requires the VAULT, not
just the token — and the vault enforces the same role check the data-governance lesson built,
not the token itself.
Practice
1. Hash the same email with a different salt and confirm the fingerprint changes.
print(hash_pii("ada@example.com", salt="a-different-salt"))
64145e9afdf12823
A different salt produces a completely unrelated hash for the same input — anyone comparing hashes across two systems using different salts will see no matches even for identical PII, which is why the salt itself must be shared (and protected) consistently, not regenerated.
2. Mask a card number shorter than 4 digits and see what breaks.
print(mask_card("123"))
123
'*' * (len(card) - 4) evaluates to '*' * -1, which Python silently treats as zero
repetitions rather than raising an error — the function quietly returns the number unmasked
instead of failing loudly. A real implementation should validate card length first.
3. Tokenize the same SSN twice. Are the two tokens the same?
t1 = tokenize(customers[0]["ssn"])
t2 = tokenize(customers[0]["ssn"])
print(t1 == t2)
False
Unlike hashing, tokenization is not deterministic by design — two tokens for the same value are unlinkable from each other without the vault, which is a deliberate privacy property (an attacker who sees the same token reused everywhere can infer it’s the same underlying value; fresh tokens per issuance avoid that).
4. Why can `hash_pii`'s output safely be used as a join key across two tables, but `tokenize`'s output cannot?
Hashing is deterministic — the same email always produces the same hash in both tables, so an equi-join on the hashed column works exactly like joining on the real email. Tokenization generates a fresh, unrelated token on every call (confirmed in task 3), so the same SSN tokenized independently in two tables produces two different tokens that would never join.
Next: observability — instrumenting a pipeline’s own operational health, not the data flowing through it.