DEV Community

Keith Ransom
Keith Ransom

Posted on

Privacy Engineering: The Technical Patterns Nobody Teaches You

Most "privacy by design" content is useless for engineers. Here's what actually matters at the implementation level.

The Gap

Privacy lawyers know what's required. Engineers know how to build systems. Almost nobody teaches the translation layer: how to turn legal requirements into concrete schema design, API contracts, and audit infrastructure.

This post covers the technical patterns that actually work.

Data Minimization at the Schema Level

The principle is simple: don't store what you don't need. The implementation details matter.

-- Bad: storing all of it "just in case"
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email TEXT,
  full_name TEXT,
  address TEXT,
  phone TEXT,
  birth_date DATE,
  ssn TEXT  -- why are you storing this?
);

-- Better: store only what your system needs, with retention
CREATE TABLE users (
  id UUID PRIMARY KEY,
  email_hash TEXT,  -- for duplicate detection; original discarded after hash
  display_name TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  data_expires_at TIMESTAMPTZ  -- enforced TTL
);
Enter fullscreen mode Exit fullscreen mode

Practical rule: if you can't name the specific feature that requires each column, drop the column.

Consent Architecture That Survives Audits

"We store it in a checkbox" is not an audit-proof consent system. GDPR Article 7 requires you to demonstrate consent — not just claim it.

A consent record needs:

  • Specific processing purpose (not "marketing" — "sending promotional emails for product X")
  • Timestamp (immutable, server-generated)
  • Version of the privacy policy at the time of consent
  • IP address and user agent (for proof of browser context)
  • Withdrawal mechanism that actually works
class ConsentRecord(Base):
    __tablename__ = "consent_records"

    id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
    user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
    purpose: Mapped[str]  # e.g. "email_marketing_v2"
    policy_version: Mapped[str]  # e.g. "2024-01-15"
    granted: Mapped[bool]
    granted_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(),
        # CRITICAL: never allow UPDATE on consent records
    )
    ip_address: Mapped[str]
    user_agent: Mapped[str]
Enter fullscreen mode Exit fullscreen mode

The immutability constraint matters: consent records should be append-only. Withdrawing consent creates a new record with granted=False, not an UPDATE to the existing one. Your audit log shows the full history.

Threat Modeling for Privacy

Standard STRIDE threat modeling doesn't surface privacy risks well. The model you want tracks:

  1. PII flows through your data flow diagrams — where does PII enter, what processes touch it, where does it exit?
  2. Third-party data sharing points — every SDK, analytics integration, and API call that receives user data
  3. Re-identification risk — data that's "anonymous" but can be combined with other data to identify individuals

A simple privacy DFD for a web app:

User Browser
    └─ [email, name] ──> Registration API
                              ├─ [email] ──> Mailchimp (3rd party! consent required)
                              ├─ [name, email] ──> PostgreSQL (your DB — Article 30 record needed)
                              └─ [email_hash] ──> Analytics (pseudonymized — lower risk)
Enter fullscreen mode Exit fullscreen mode

Every arrow that crosses to a third party is a data processing agreement (DPA) requirement under GDPR Article 28.

Zero-Knowledge Local Architecture

For the highest-sensitivity applications, design where the server never sees plaintext PII.

Pattern: client-side encryption before upload.

// Client generates and holds the key
const key = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 256 },
  true,  // extractable, for export to user's keychain
  ["encrypt", "decrypt"]
);

// Encrypt before sending to server
async function uploadDocument(plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    new TextEncoder().encode(plaintext)
  );

  // Server stores only ciphertext + iv — can't read the content
  await api.post("/documents", { ciphertext, iv });
}
Enter fullscreen mode Exit fullscreen mode

The server can't produce a breach of plaintext data it never had. Entire categories of compliance exposure disappear.

Audit Logging That Actually Works

Most audit logs capture "what happened." Privacy audit logs need to capture "what happened to whose data, for what purpose, under what authorization."

Minimum viable privacy audit record:

  • Subject (whose data was accessed)
  • Actor (who accessed it — user, system, admin, third-party)
  • Action (read, write, delete, export)
  • Purpose (what processing purpose authorized this)
  • Legal basis (consent ID, legitimate interest assessment reference, contract clause)
  • Timestamp (immutable, server-generated)

These records need to be tamper-evident. Consider append-only storage, cryptographic chaining, or a dedicated audit log service.

The Handbook

I wrote a full technical guide covering these patterns at implementation depth — schema designs, API contracts, audit log formats, consent architecture, and zero-knowledge patterns — built while constructing a privacy infrastructure product from scratch with zero cloud dependency.

Privacy Engineering Handbook — $47 →

One-time payment. No subscription. Outset Solutions is a Service-Disabled Veteran-Owned Small Business (SDVOSB).


What privacy engineering challenges are you dealing with? Drop them in the comments.

Top comments (0)