Privacy Engineering

Security keeps attackers out; privacy controls what the organization itself does with the data it legitimately holds. Privacy engineering is the discipline of translating abstract legal principles — GDPR’s “data protection by design and by default,” the CCPA’s consumer rights — into concrete architecture: schemas that collect less, pipelines that mask and tokenize, queries that add calibrated noise, and consent records that gate every downstream use. This page covers privacy-by-design, data minimization and retention, PII discovery and masking/tokenization, differential privacy in practice, consent management, and the operational reality of GDPR and CCPA/CPRA.

Privacy ≠ security. A perfectly encrypted, breach-free system can still violate privacy by collecting too much, keeping it too long, or using it for purposes the subject never agreed to. Privacy engineering targets what you do with data, not just who can steal it.

The controls on this page attach to a data lifecycle: each stage has a corresponding technique that limits exposure.

flowchart LR
    A["Collect"] --> B["Store"]
    B --> C["Use / analyze"]
    C --> D["Share"]
    D --> E["Retain"]
    E --> F["Delete"]
    A -.- a["Minimization,<br/>consent, purpose binding"]
    B -.- b["Encryption,<br/>tokenization, access control"]
    C -.- c["Masking,<br/>differential privacy"]
    D -.- d["Consent check,<br/>opt-out / GPC"]
    E -.- e["Retention schedule,<br/>legal hold"]
    F -.- f["Erasure,<br/>crypto-shredding"]

Privacy by Design

“Privacy by Design” (PbD), formalized by Ann Cavoukian in the 1990s and later written into GDPR Article 25 as data protection by design and by default, is the principle that privacy must be engineered into systems from the start rather than bolted on after launch. The seven foundational principles, in engineering terms:

Principle What it means in code/architecture
Proactive, not reactive Threat-model privacy harms during design review, before the first row is collected
Privacy as the default Opt-in, not opt-out; the most protective setting ships enabled
Privacy embedded into design Minimization and access control are schema/IAM concerns, not a banner
Full functionality (positive-sum) Reject the false choice “privacy or features” — solve for both
End-to-end security Encrypt at rest and in transit across the full data lifecycle
Visibility and transparency Data flows are documented, auditable, and explainable to subjects
Respect for user privacy User-centric defaults, clear notices, easy rights exercise

Privacy by Default in Practice

The “by default” half of Article 25 is the one most often violated. It says the factory setting must be the privacy-protective one.

# ANTI-PATTERN: privacy is opt-out, defaults leak data
class UserProfile:
    def __init__(self):
        self.share_with_partners = True    # on by default — non-compliant
        self.public_profile = True          # exposed unless user hunts for the toggle
        self.retain_forever = True

# PRIVACY BY DEFAULT: most protective setting ships enabled
class UserProfile:
    def __init__(self):
        self.share_with_partners = False   # requires explicit opt-in
        self.public_profile = False        # visible to the user only
        self.purpose = "account_management" # bound to a declared purpose
        self.retention_days = 365           # bounded, not "forever"

The Privacy Threat Model: LINDDUN

Where security threat modeling uses STRIDE, privacy engineering uses LINDDUN, a mnemonic for seven privacy threat categories checked against every element of a system’s data-flow diagram:

Threat The risk
Linking Correlating records back to one person across sources
Identifying Re-identifying someone behind a “pseudonym”
Non-repudiation A subject cannot plausibly deny an action they wanted private
Detecting Inferring that someone is in the dataset from observable side effects
Data disclosure Excessive or unnecessary exposure of personal data
Unawareness The subject cannot understand or act on their rights
Non-compliance The system’s practice diverges from its stated policy or the law

For each data flow, store, and process, you ask “which of these can happen here?” and add a control. Linking and Identifying are the engine behind most re-identification scandals — the 1990s Massachusetts GIC release, the “anonymized” Netflix Prize ratings, and AOL search logs were all defeated by joining quasi-identifiers across datasets. The LINDDUN project now also publishes a lightweight card-deck variant (LINDDUN GO) for faster workshops.

Data Minimization and Retention

Two of the strongest, cheapest privacy controls are simply collecting less and keeping it for less time. Data you never collected cannot be breached, subpoenaed, sold, or misused.

Minimization: Collect Only What the Purpose Requires

GDPR Article 5(1)(c) requires personal data to be “adequate, relevant and limited to what is necessary.” The engineering test is the purpose binding question: for each field, which declared purpose makes this column necessary? If none, drop it.

# The minimization checklist, encoded as a schema gate
REQUIRED_FOR = {
    "email":        {"account_login", "transactional_email"},
    "shipping_addr":{"order_fulfillment"},
    "date_of_birth":{"age_verification"},          # store age band, not raw DOB, if possible
    # "phone":      collected "just in case" -> NO declared purpose -> reject
}

def validate_collection(field, active_purposes):
    needed_by = REQUIRED_FOR.get(field, set())
    if not (needed_by & active_purposes):
        raise PrivacyViolation(
            f"Field '{field}' has no purpose justifying collection"
        )

Common minimization moves:

  • Derive, don’t store. Keep an age_band (“25-34”), not a raw date of birth; a country, not a precise GPS fix.
  • Truncate at the edge. Drop the last octet of an IP, round timestamps to the hour.
  • Reference, don’t copy. Hold a tokenized handle to data in a system of record rather than replicating PII into every microservice.

Retention: Keep It Only as Long as the Purpose Lasts

“Storage limitation” (Article 5(1)(e)) requires data to be kept “no longer than is necessary.” Implement it as a retention schedule — a per-category TTL with automated, logged deletion:

# Retention schedule: category -> max age, enforced by a scheduled job
RETENTION = {
    "session_logs":      timedelta(days=30),
    "order_history":     timedelta(days=365 * 7),   # tax/accounting obligation
    "marketing_profile": timedelta(days=180),
    "support_tickets":   timedelta(days=365 * 2),
}

def enforce_retention(now):
    for category, max_age in RETENTION.items():
        cutoff = now - max_age
        deleted = db.delete(category, where=f"created_at < {cutoff}")
        audit_log.record("retention_deletion", category=category,
                         rows=deleted, cutoff=cutoff)  # prove compliance later

Note the tension with legal hold and statutory retention: tax records, KYC/AML data, and litigation holds override a delete request. A correct retention engine knows the difference between “the user asked us to forget them” and “the law requires us to keep this for seven years.”

Crypto-shredding. When physical deletion is impractical (immutable backups, append-only logs), encrypt each subject’s data under a per-subject key and destroy the key to render the ciphertext permanently unreadable. This is the standard pattern for honoring erasure against backups.

PII Discovery, Masking, and Tokenization

You cannot protect data you cannot find. Mature programs run continuous PII discovery (data classification) across databases, object storage, logs, and data lakes — then apply masking or tokenization to reduce exposure.

Discovery and Classification

Classification scanners combine pattern matching (regex for emails, SSNs, card numbers with Luhn validation), dictionary/NER models for names and addresses, and column-statistics heuristics. Cloud-native tools (AWS Macie, Google Cloud DLP, Microsoft Purview) tag findings with a sensitivity level that downstream access policies key off.

import re

# Validators are stronger than naked regex: a Luhn check rejects most false positives
def looks_like_card(s):
    digits = [int(c) for c in s if c.isdigit()]
    if not 13 <= len(digits) <= 19:
        return False
    # Luhn checksum
    total, parity = 0, len(digits) % 2
    for i, d in enumerate(digits):
        if i % 2 == parity:
            d *= 2
            if d > 9:
                d -= 9
        total += d
    return total % 10 == 0

PII_PATTERNS = {
    "email": re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"),
    "ssn":   re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
    "ipv4":  re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
}

Masking vs. Tokenization vs. Encryption

These three are constantly confused. They solve different problems:

Technique Reversible? Format-preserving? Typical use
Masking / redaction No (static) Often Non-prod test data, UI display (****1234), logs
Tokenization Yes, via a token vault Yes Payment cards (PCI scope reduction), SSNs, analytics joins
Encryption Yes, with the key No (ciphertext) Data at rest/in transit, anywhere you need the value back

Masking irreversibly replaces a value, optionally preserving format. Static masking produces a sanitized copy (e.g., a scrubbed staging database); dynamic masking rewrites query results at read time based on the caller’s role.

def mask_pan(card_number):
    # PCI-style display masking: first 6 + last 4 visible, rest hidden
    n = len(card_number)
    return card_number[:6] + "*" * (n - 10) + card_number[-4:]

def mask_email(addr):
    user, _, domain = addr.partition("@")
    head = user[0] if user else ""
    return f"{head}***@{domain}"

Tokenization swaps a sensitive value for a non-sensitive surrogate (the token) and stores the mapping in a hardened token vault. Crucially, the token has no mathematical relationship to the original — unlike ciphertext, you cannot brute-force it without the vault. This is why PCI DSS treats a properly tokenized environment as out of scope for cardholder data: the application databases hold only tokens.

import secrets

class TokenVault:
    """Maps sensitive values to random surrogates. The vault is the only
    PCI-in-scope component; everything downstream stores tokens only."""
    def __init__(self):
        self._to_token = {}   # value -> token (deterministic for join stability)
        self._to_value = {}   # token -> value

    def tokenize(self, value):
        if value in self._to_token:          # deterministic: same value -> same token
            return self._to_token[value]      # so analytics joins still work
        token = "tok_" + secrets.token_urlsafe(16)
        self._to_token[value] = token
        self._to_value[token] = value
        return token

    def detokenize(self, token):
        # Access-controlled, audited, and rate-limited in a real vault
        return self._to_value[token]

# Application & analytics see only "tok_..."; raw PANs never leave the vault.

Format-preserving encryption (FPE) is a middle ground: ciphertext that keeps the original format (a 16-digit “card number” maps to another 16-digit string), useful when a legacy schema demands a specific shape but you still need reversibility under a key. NIST specifies FPE in SP 800-38G. Note the moving target here: after Betül Durak and colleagues, and later Ohad Amon, Orr Dunkelman, and Eyal Ronen, showed practical attacks against the FF3/FF3-1 tweak schedule, NIST’s 2025 draft revision (SP 800-38G Rev. 1) removes FF3-1 entirely and retains only FF1, tightening it to a minimum domain of one million values. New designs should use FF1 over a large domain, or prefer tokenization where the format constraint is the only reason FPE was considered.

Differential Privacy in Practice

Masking and tokenization protect records. They do nothing against inference attacks, where an analyst learns about an individual from aggregate statistics — the classic example being two near-identical queries (“average salary of all employees” vs. “average salary excluding Alice”) that reveal Alice’s salary by subtraction. Differential privacy (DP) is the rigorous defense.

The Definition

A randomized mechanism $M$ is ε-differentially private if, for any two datasets $D$ and $D’$ that differ in a single individual’s record, and for every possible output $S$:

\[\Pr[M(D) \in S] \le e^{\varepsilon} \cdot \Pr[M(D') \in S]\]

Intuitively: whether or not your record is in the dataset, the distribution of outputs barely changes — so the output cannot reveal much about you specifically. The privacy budget ε is the knob: small ε (e.g., 0.1) means strong privacy and noisier answers; large ε (e.g., 10) means weak privacy and accurate answers. ε is consumed across queries and composes additively, so a fixed total budget must be allocated carefully.

The relaxed (ε, δ)-DP allows a small failure probability δ (kept far below $1/n$ for a dataset of $n$ people):

\[\Pr[M(D) \in S] \le e^{\varepsilon} \cdot \Pr[M(D') \in S] + \delta\]

This relaxation is what the Gaussian mechanism (adding normally-distributed noise) satisfies, and it is the basis for the tight composition accounting — Rényi DP and the “moments accountant” — that makes training ML models with DP-SGD practical. For choosing and defending ε in a real deployment, NIST published SP 800-226, Guidelines for Evaluating Differential Privacy Guarantees (2025), which catalogs the common “DP hazards” (mis-set neighboring definitions, floating-point leaks, unbounded sensitivity) that quietly void the guarantee in practice.

The Laplace Mechanism

For a numeric query $f$, DP is achieved by adding noise calibrated to the query’s sensitivity — the most the answer can change when one person’s record changes:

\[\Delta f = \max_{D, D'} \lvert f(D) - f(D') \rvert\]

Add Laplace noise with scale $b = \Delta f / \varepsilon$:

\[M(D) = f(D) + \mathrm{Laplace}\!\left(0, \frac{\Delta f}{\varepsilon}\right)\]
import numpy as np

def dp_count(true_count, epsilon):
    # A count query has sensitivity 1: adding/removing one person changes
    # the count by at most 1.
    sensitivity = 1.0
    noise = np.random.laplace(0.0, sensitivity / epsilon)
    return true_count + noise

def dp_mean(values, lower, upper, epsilon):
    # Clamp to a known range so sensitivity is bounded, then split the budget
    # across the sum and the count (each is its own query).
    vals = np.clip(values, lower, upper)
    n = len(vals)
    sens_sum = upper - lower            # one record moves the sum by <= range
    noisy_sum   = vals.sum() + np.random.laplace(0, sens_sum / (epsilon / 2))
    noisy_count = n          + np.random.laplace(0, 1.0      / (epsilon / 2))
    return noisy_sum / max(noisy_count, 1)

Local vs. Central DP, and Real Deployments

  • Central DP trusts a curator to hold raw data and add noise to released aggregates (smaller noise, used by the US Census Bureau for the 2020 redistricting data).
  • Local DP has each user randomize their own value before it ever leaves the device (larger noise, but no trusted curator). Apple’s keyboard/emoji telemetry uses local DP, and Google’s RAPPOR in Chrome pioneered the approach for browser statistics.

DP is not a silver bullet. A tight ε is hard to spend across many queries, choosing ε is a policy decision with real utility cost, and DP protects against inference — it does not replace access control, encryption, or minimization. Use it with the other controls.

Several legal bases permit processing (contract, legal obligation, legitimate interest), but consent is the one that requires the most engineering. Under GDPR, valid consent must be freely given, specific, informed, and unambiguous, captured by a clear affirmative action — pre-ticked boxes and “by using this site you agree” banners are invalid.

A Consent Management Platform (CMP) must do four things well:

  1. Capture granular, per-purpose consent with a clear affirmative action.
  2. Store an immutable, timestamped record (proof of consent is the controller’s burden).
  3. Enforce it — every downstream process checks consent before acting.
  4. Honor withdrawal — withdrawing must be as easy as granting, and it propagates.
from dataclasses import dataclass, field
from datetime import datetime, timezone

@dataclass(frozen=True)            # immutable: an audit record, never edited
class ConsentRecord:
    subject_id: str
    purpose: str                   # "analytics", "marketing_email", ...
    granted: bool
    timestamp: datetime
    policy_version: str            # which notice the subject actually saw
    method: str = "explicit_optin"

class ConsentLedger:
    def __init__(self):
        self._events = []          # append-only history

    def record(self, subject_id, purpose, granted, policy_version):
        self._events.append(ConsentRecord(
            subject_id, purpose, granted,
            datetime.now(timezone.utc), policy_version))

    def has_consent(self, subject_id, purpose):
        # Latest event wins; default-deny if no affirmative grant exists
        relevant = [e for e in self._events
                    if e.subject_id == subject_id and e.purpose == purpose]
        return bool(relevant) and relevant[-1].granted

# Enforcement: the gate every processing path must pass through
def send_marketing_email(subject_id, ledger):
    if not ledger.has_consent(subject_id, "marketing_email"):
        raise PrivacyViolation("No valid consent for marketing_email")
    ...

The CCPA/CPRA model inverts the default: most processing is permitted, but consumers have a right to opt out of the “sale” or “sharing” of personal information, surfaced through a “Do Not Sell or Share My Personal Information” link and honored automatically via the Global Privacy Control (GPC) browser signal. GPC is now a legally binding universal opt-out signal in a growing list of states — California, Colorado, Connecticut, New Jersey (from mid-2025), and Oregon (from January 2026) among them — and regulators have enforced it: California settlements include Sephora ($1.2M, 2022), Healthline ($1.55M, 2025), and Disney ($2.75M, 2026) for failing to honor opt-outs. California’s AB 566 goes further, requiring browsers sold in the state to offer a built-in opt-out-signal control starting January 1, 2027, which will push GPC from an opt-in extension toward a default. A robust CMP handles both the GDPR opt-in world and the CCPA opt-out world from one consent ledger.

The Practical Side of GDPR and CCPA

Beyond consent, both regimes grant data subjects/consumers a set of rights that the system must be able to service — usually within a statutory deadline. The hard part is rarely the policy; it is having the engineering to find, export, and delete a single person’s data spread across dozens of services and backups.

Data Subject Rights, as Engineering Requirements

Right (GDPR / CCPA term) What the system must be able to do
Access / Right to Know Assemble all data about a subject across every store into a portable export
Erasure / Deletion (“right to be forgotten”) Delete or crypto-shred across primaries, replicas, caches, logs, and backups
Rectification Correct inaccurate data and propagate the fix downstream
Portability Export in a structured, machine-readable, interoperable format
Restriction / Opt-out Suppress processing for specific purposes without deleting
Object / Do Not Sell Stop “sale”/”sharing”; honor the GPC signal

The deletion right exposes the distributed-deletion problem: a subject_id may appear in the orders DB, the analytics warehouse, the email provider, the CDN logs, and last night’s backup. A Data Subject Access Request (DSAR) orchestrator fans the request out to every registered system and tracks completion.

# DSAR orchestrator: fan out to every system that might hold the subject's data
SYSTEMS = ["users_db", "orders_db", "analytics_dw", "email_provider", "cdn_logs"]

def handle_erasure_request(subject_id, deadline_days=30):
    job = DsarJob(subject_id, kind="erasure", deadline_days=deadline_days)
    for system in SYSTEMS:
        # Each connector knows how to find + delete (or crypto-shred) the subject
        result = connectors[system].erase(subject_id)
        job.record(system, result)
    # Backups: defer to crypto-shred or schedule deletion on next backup rotation
    job.note("backups", "key destroyed; ciphertext unrecoverable")
    audit_log.record("erasure_completed", subject=subject_id, systems=SYSTEMS)
    return job

Breach Notification and DPIAs

Two operational obligations bridge privacy and the incident response process:

  • Breach notification. GDPR Article 33 requires notifying the supervisory authority within 72 hours of becoming aware of a personal-data breach; affected individuals must be told without undue delay if there is high risk to them. US state laws have their own (often faster) timelines.
  • Data Protection Impact Assessment (DPIA). Required (Article 35) before high-risk processing — large-scale profiling, sensitive categories, systematic monitoring. The DPIA documents the data flows, the LINDDUN-style risks, and the mitigations, and is the artifact regulators ask for first.

GDPR vs. CCPA/CPRA at a Glance

Dimension GDPR (EU) CCPA / CPRA (California)
Scope Anyone processing EU residents’ data For-profits over revenue/volume thresholds serving CA residents
Default Opt-in (consent or other lawful basis) Opt-out (of sale/sharing)
Key control Lawful basis + explicit consent “Do Not Sell or Share” + GPC
Erasure Right to be forgotten Right to delete (with exceptions)
Breach window 72 hours to authority Varies; “without unreasonable delay”
Penalty ceiling Up to €20M or 4% of global turnover ~$2,663 per violation, ~$7,988 if intentional or involving a minor (CPI-adjusted)

New for engineering teams: the California Privacy Protection Agency’s ADMT, risk-assessment, and cybersecurity-audit regulations were finalized in September 2025 and took effect January 1, 2026 (with phased compliance deadlines). They add three concrete obligations beyond the rights above — a documented risk assessment before certain high-risk processing, an annual independent cybersecurity audit for qualifying businesses, and consumer rights to access and opt out of automated decision-making technology (ADMT) — which pull DPIA-style documentation and audit trails firmly into the US regime, not just the EU one.

The architectural takeaway: build for the stricter regime (GDPR’s opt-in, purpose-bound, minimized model) and the looser one falls out almost for free, while a single consent ledger and DSAR orchestrator serve every jurisdiction you operate in.


See Also

  • Operations, Response & Compliance — incident response, breach handling, and the broader compliance landscape
  • Cryptography — the encryption, hashing, and key management underpinning crypto-shredding and tokenization
  • Web, Cloud & Container Security — IAM and access controls that enforce purpose binding in practice
  • Attacks & Network Defense — how the breaches that trigger notification obligations actually happen
  • Database Design — schema design, where minimization and retention are ultimately enforced
  • AWS — cloud DLP, classification, and key-management services for privacy controls