Web Engineering ·

GDPR Engineering for SaaS Teams: Consent Management, Data Subject Requests, and Privacy-by-Design Architecture

The engineering patterns behind GDPR compliance for SaaS products: consent storage and versioning, DSAR automation pipelines, right-to-erasure cascades, Article 30 records, and privacy-by-design architecture in TypeScript.

GDPR Engineering for SaaS Teams: Consent Management, Data Subject Requests, and Privacy-by-Design Architecture

GDPR compliance is largely an engineering problem dressed in legal language. Most of the fines, most of the failed audits, and most of the last-minute scrambles happen because engineering teams treat it as a checkbox exercise rather than an architectural constraint. This article is not legal advice. It is the engineering side: what to build, how to build it, and what to avoid.

The focus is on SaaS products with a Postgres-based backend and TypeScript throughout. The patterns generalize to other stacks, but the code is concrete.

The Data Inventory Problem

You cannot manage what you have not mapped. Before writing any compliance code, produce a data inventory: every table that holds personal data, what category it falls into (contact data, behavioral data, health data), the legal basis for processing it, and how long it should be retained. This is not optional. Article 30 of GDPR requires Records of Processing Activities (RoPA), and your data inventory is the engineering input to that document.

A practical schema for tracking this programmatically:

// types/data-inventory.ts
export type LegalBasis =
  | "consent"
  | "contract"
  | "legal_obligation"
  | "vital_interests"
  | "public_task"
  | "legitimate_interests";

export type DataCategory =
  | "contact"
  | "behavioral"
  | "financial"
  | "health"
  | "location"
  | "biometric"
  | "special_category";

export interface DataAsset {
  id: string;
  table: string;
  columns: string[];
  category: DataCategory;
  legalBasis: LegalBasis;
  purposeDescription: string;
  retentionDays: number;
  crossBorderTransfer: boolean;
  transferMechanism?: "adequacy_decision" | "scc" | "bcr" | "derogation";
  processorName?: string; // third party if applicable
}

Keep this inventory as a source-controlled TypeScript file. It becomes living documentation, feeds your Article 30 records, and drives the automation in the sections below. When a team adds a new table with personal data, the PR review should include an update to this file.

Consent under GDPR requires specific conditions: freely given, specific, informed, and unambiguous. For multi-purpose processing (marketing emails, analytics, third-party sharing), each purpose needs a separate consent record.

The consent table needs to track what version of the policy the user consented to, which purposes they accepted, and the exact timestamp. You need the version because if your privacy policy changes and you reuse old consent records, that is a violation.

// db/schema/consent.ts
import { pgTable, uuid, text, timestamp, boolean, jsonb } from "drizzle-orm/pg-core";

export const consentRecords = pgTable("consent_records", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull(),
  policyVersion: text("policy_version").notNull(), // e.g. "2026-01-15"
  purposes: jsonb("purposes").notNull().$type<Record<string, boolean>>(),
  // e.g. { marketing: true, analytics: false, third_party_sharing: false }
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  collectionMethod: text("collection_method").notNull(), // "signup_form" | "settings_page" | "cookie_banner"
  givenAt: timestamp("given_at", { withTimezone: true }).notNull().defaultNow(),
  withdrawnAt: timestamp("withdrawn_at", { withTimezone: true }),
  // Never delete consent records, even after withdrawal. Withdrawal is a new event.
});

// The active consent for a user is the most recent non-withdrawn record.

A few constraints worth enforcing at the application layer:

  • Never update a consent record in place. Consent history must be immutable. If a user changes preferences, insert a new record and set withdrawnAt on the old one.
  • Store the policy version, not just a boolean. Regulators ask “what did the user agree to?” You need to answer that with a document, not just a timestamp.
  • Capture the collection method. Consent obtained via a pre-ticked checkbox is invalid. The collection method field lets you audit this.

Querying active consent for a given purpose:

// lib/consent.ts
import { db } from "@/db";
import { consentRecords } from "@/db/schema/consent";
import { eq, isNull, desc } from "drizzle-orm";

export async function hasActiveConsent(
  userId: string,
  purpose: string
): Promise<boolean> {
  const record = await db
    .select()
    .from(consentRecords)
    .where(eq(consentRecords.userId, userId))
    .where(isNull(consentRecords.withdrawnAt))
    .orderBy(desc(consentRecords.givenAt))
    .limit(1);

  if (!record.length) return false;

  const purposes = record[0].purposes as Record<string, boolean>;
  return purposes[purpose] === true;
}

For cookie consent specifically, the storage mechanism is often a signed cookie or a server-side record keyed to a session identifier before login. The important detail is that analytics scripts and third-party pixels must not fire until consent is confirmed. Gate them at the application layer, not just the frontend.

Data Subject Access Requests: Automating DSAR Pipelines

Article 15 gives users the right to a copy of all personal data you hold about them. You have 30 days to respond. For most early-stage SaaS teams, this process is fully manual, which works at low volume but collapses at scale.

The architecture for DSAR automation has three parts: a request intake endpoint, a data collection pipeline, and a delivery mechanism.

// api/dsar/request.ts (Hono handler)
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "@/db";
import { dsarRequests } from "@/db/schema/dsar";
import { verifyUserIdentity } from "@/lib/identity";
import { enqueueDataExport } from "@/jobs/data-export";

const app = new Hono();

const RequestSchema = z.object({
  type: z.enum(["access", "erasure", "portability", "rectification"]),
  verificationToken: z.string(), // out-of-band identity verification
});

app.post("/dsar/request", zValidator("json", RequestSchema), async (c) => {
  const { type, verificationToken } = c.req.valid("json");
  const userId = c.get("userId"); // from auth middleware

  // Identity verification is mandatory before processing any DSAR.
  // Do not rely solely on session authentication for erasure requests.
  const verified = await verifyUserIdentity(userId, verificationToken);
  if (!verified) {
    return c.json({ error: "Identity verification required" }, 403);
  }

  const request = await db
    .insert(dsarRequests)
    .values({
      userId,
      type,
      status: "pending",
      requestedAt: new Date(),
      dueAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
    })
    .returning();

  await enqueueDataExport({ dsarRequestId: request[0].id, type });

  return c.json({ requestId: request[0].id, dueAt: request[0].dueAt });
});

The data collection job assembles a complete export. The key engineering challenge here is breadth: you need data from every service, every table, and every third-party processor that holds data for this user. The data inventory from step one drives this directly.

// jobs/data-export.ts
import { db } from "@/db";
import { DataAsset } from "@/types/data-inventory";
import { dataInventory } from "@/config/data-inventory"; // your typed inventory file

interface ExportResult {
  asset: string;
  records: unknown[];
}

async function collectUserData(userId: string): Promise<ExportResult[]> {
  const results: ExportResult[] = [];

  for (const asset of dataInventory) {
    if (!asset.columns.includes("user_id")) continue;

    // Dynamic query per asset — each asset knows its own table
    const rows = await db.execute(
      `SELECT ${asset.columns.join(", ")} FROM ${asset.table} WHERE user_id = $1`,
      [userId]
    );

    if (rows.rows.length > 0) {
      results.push({ asset: asset.table, records: rows.rows });
    }
  }

  return results;
}

export async function enqueueDataExport({
  dsarRequestId,
  type,
}: {
  dsarRequestId: string;
  type: string;
}): Promise<void> {
  // Push to a job queue (BullMQ, Inngest, etc.)
  // The job calls collectUserData, packages as JSON/CSV, and
  // uploads to a signed URL the user can download within 24 hours.
}

Third-party data is where DSAR pipelines usually break. Stripe holds payment data. Intercom holds conversation history. Mixpanel holds event data. You are the data controller; they are processors. You are responsible for including their data in DSAR responses. Most have APIs for this. Build adapters for each processor and include them in the collection pipeline.

Right to Erasure: Cascading Deletion Across Services

The right to erasure (Article 17) is architecturally harder than access because deletion propagates across your entire data estate. The correct pattern is event-driven cascading deletion, not direct cascades at the database level.

// events/user-deleted.ts
export interface UserDeletedEvent {
  type: "user.deleted";
  userId: string;
  requestedAt: string; // ISO 8601
  dsarRequestId: string;
  deletionType: "hard" | "anonymize";
  // hard = remove rows, anonymize = null out PII fields in place
}

Publish this event to your message queue. Every service that holds data for this user subscribes and handles its own deletion. This avoids tight coupling and handles external processors: your Stripe adapter subscribes to user.deleted, calls stripe.customers.del(customerId), and logs the result.

The hard delete vs. anonymize decision depends on your data class:

Data classRecommended approachReason
Contact data (email, name)Hard deleteNo legitimate retention need after erasure
Audit logsAnonymize in placeLegal obligation to retain activity records
Financial transaction recordsRetain per legal obligation7-year retention for tax/accounting law
Behavioral analyticsHard deleteNo legal basis survives erasure request
Consent recordsRetain (as proof of compliance)Recital 65 GDPR: compliance records exempt

For anonymization in place, null out PII columns but keep the row structure intact so foreign key relationships and aggregate analytics remain valid:

// lib/erasure.ts
import { db } from "@/db";
import { users, userProfiles, activityLogs } from "@/db/schema";
import { eq } from "drizzle-orm";

export async function anonymizeUser(userId: string): Promise<void> {
  await db.transaction(async (tx) => {
    // Anonymize the user record
    await tx
      .update(users)
      .set({
        email: `deleted-${userId}@deleted.invalid`,
        name: null,
        phoneNumber: null,
        deletedAt: new Date(),
      })
      .where(eq(users.id, userId));

    // Anonymize profile data
    await tx
      .update(userProfiles)
      .set({
        avatarUrl: null,
        bio: null,
        location: null,
      })
      .where(eq(userProfiles.userId, userId));

    // Activity logs: keep the event type and timestamp, null the user link
    await tx
      .update(activityLogs)
      .set({ userId: null, ipAddress: null, userAgent: null })
      .where(eq(activityLogs.userId, userId));
  });
}

One gotcha: backups. After erasure, the user’s data may persist in database backups for weeks. Your privacy policy must disclose backup retention periods, and your backup rotation policy must enforce them. This is an operational detail that is easy to overlook during the architecture phase.

Privacy-by-Design: Data Minimization and Pseudonymization

Privacy-by-design (Article 25) means collecting only what you need and building systems that minimize exposure by default. In practice this means two concrete patterns: data minimization at collection time, and pseudonymization for data that must be retained.

Data minimization is a code review concern as much as an architecture concern. Any time a new field is added to a user-facing form or a new event property is logged, ask: do we need this? Is there a legal basis? How long do we keep it? Adding this to your PR template is low-friction and high-value.

Pseudonymization separates identifying information from analytical data. A pseudonymized analytics event links to a pseudonym identifier rather than the user’s email or name. If the analytics database is breached, the attacker gets event records but cannot link them to real identities without the mapping table.

// lib/pseudonymization.ts
import { createHmac } from "crypto";

const PSEUDO_KEY = process.env.PSEUDONYMIZATION_KEY!;
// Store this key in your secrets manager, not in application config.
// If you need to erase a user's pseudonymized data, rotate this key
// and the existing pseudo IDs become unlinkable.

export function pseudonymize(userId: string): string {
  return createHmac("sha256", PSEUDO_KEY).update(userId).digest("hex");
}

// Usage in event tracking:
export interface AnalyticsEvent {
  pseudoUserId: string; // never the real userId
  event: string;
  properties: Record<string, unknown>;
  timestamp: string;
}

export function trackEvent(
  userId: string,
  event: string,
  properties: Record<string, unknown>
): AnalyticsEvent {
  return {
    pseudoUserId: pseudonymize(userId),
    event,
    properties,
    timestamp: new Date().toISOString(),
  };
}

Encryption at rest for special category data (health data, financial data) is table-level or column-level encryption using a KMS-managed key. Most cloud providers offer transparent disk encryption at the infrastructure layer, but that does not protect against compromised application credentials. Column-level encryption with envelope encryption (a data key encrypted by a KMS-managed CMK) provides an additional protection layer for your highest-sensitivity fields.

Data in transit requires TLS 1.2 minimum on all endpoints, including internal service-to-service traffic. Mutual TLS for internal APIs is worth the operational overhead if you process special category data.

Article 30 Records of Processing Activities

Article 30 requires you to maintain written records of your processing activities. This is not just a document; it is a living record that must reflect your actual data processing. The data inventory you built earlier is the source of truth.

Generate Article 30 records programmatically from your inventory:

// scripts/generate-ropa.ts
import { dataInventory } from "@/config/data-inventory";
import { writeFileSync } from "fs";

interface RoPAEntry {
  processingActivity: string;
  controller: string;
  dataCategories: string[];
  legalBasis: string;
  purposeOfProcessing: string;
  retentionPeriod: string;
  recipients: string[];
  crossBorderTransfers: string;
  technicalMeasures: string[];
}

function generateRoPA(): RoPAEntry[] {
  return dataInventory.map((asset) => ({
    processingActivity: `Processing of ${asset.category} data in ${asset.table}`,
    controller: "Your Company Ltd",
    dataCategories: [asset.category],
    legalBasis: asset.legalBasis,
    purposeOfProcessing: asset.purposeDescription,
    retentionPeriod: `${asset.retentionDays} days`,
    recipients: asset.processorName ? [asset.processorName] : ["Internal only"],
    crossBorderTransfers: asset.crossBorderTransfer
      ? `Transfer mechanism: ${asset.transferMechanism}`
      : "No cross-border transfer",
    technicalMeasures: ["Encryption at rest", "TLS in transit", "Access controls"],
  }));
}

const ropa = generateRoPA();
writeFileSync(
  "compliance/ropa.json",
  JSON.stringify(ropa, null, 2)
);

Run this script as part of your CI pipeline whenever the data inventory changes. The output feeds your Data Protection Officer (or whoever handles compliance) and forms the basis of your Article 30 documentation.

Cross-Border Data Transfers

If your SaaS serves EU users and your infrastructure (or third-party processors) sits outside the EU/EEA, you need a legal mechanism for the transfer. The options are:

  • Adequacy decisions: The EU has deemed certain countries (UK, Switzerland, Japan, others) to provide adequate protection. No additional mechanism needed.
  • Standard Contractual Clauses (SCCs): The 2021 SCCs are the default mechanism for transfers to countries without adequacy decisions, including the US. You need signed SCCs with every processor that receives EU personal data.
  • Binding Corporate Rules (BCRs): For intra-group transfers within a multinational company. Complex to implement, but covers all group entities.

The engineering implication of SCCs is data residency configuration. Many cloud providers offer region-specific deployments. If you use AWS, you can deploy into eu-west-1 and configure S3 bucket policies to deny cross-region replication for EU personal data:

// config/s3-policy.ts
export const euDataBucketPolicy = {
  Version: "2012-10-17",
  Statement: [
    {
      Sid: "DenyNonEURegionAccess",
      Effect: "Deny",
      Principal: "*",
      Action: "s3:*",
      Resource: [
        "arn:aws:s3:::your-eu-user-data-bucket",
        "arn:aws:s3:::your-eu-user-data-bucket/*",
      ],
      Condition: {
        StringNotEquals: {
          "aws:RequestedRegion": ["eu-west-1", "eu-central-1"],
        },
      },
    },
  ],
};

The processor register in your data inventory should flag every third-party that receives EU personal data and note the transfer mechanism. Audit this register when you add new integrations.

Common Engineering Pitfalls

Logging personal data. Application logs routinely capture request bodies, headers, and query parameters. If your logs contain email addresses, IP addresses, or session tokens, they are personal data under GDPR. Scrub PII from logs at the ingestion layer. Structured logging with an explicit allowlist of fields is safer than free-form string logs.

// lib/logger.ts
const ALLOWED_LOG_FIELDS = [
  "requestId", "method", "path", "statusCode",
  "durationMs", "userId", // pseudonymized, see above
] as const;

type LogFields = {
  [K in typeof ALLOWED_LOG_FIELDS[number]]?: string | number;
};

export function logRequest(fields: LogFields): void {
  // Only log fields explicitly in the allowlist
  const sanitized = Object.fromEntries(
    Object.entries(fields).filter(([k]) =>
      ALLOWED_LOG_FIELDS.includes(k as typeof ALLOWED_LOG_FIELDS[number])
    )
  );
  console.log(JSON.stringify(sanitized));
}

Implicit consent via continued use. Consent cannot be inferred from silence or inactivity. “By using this service you agree to marketing emails” in terms of service is not valid consent for marketing purposes. Consent requires an affirmative action per purpose.

Forgetting soft-deleted records. A soft-delete pattern (adding deleted_at to a row) does not erase the data. If a user exercises the right to erasure and you only set deleted_at, you have not complied. Your erasure pipeline must handle soft-deleted rows.

Not testing DSAR response completeness. Write a test that creates a user, performs typical application actions (profile update, activity events, purchases), submits a DSAR access request, and asserts that every table in the data inventory is represented in the export. This test will catch new tables that developers add without updating the inventory.

Mixing consent and contract as legal bases. If you process data under contract (to deliver the service the user signed up for), you cannot also cite consent for that same processing. Consent must be for processing beyond what is strictly necessary for the contract. Using the wrong legal basis is a common source of enforcement action.

Shared infrastructure between tenants leaking in DSAR responses. In multi-tenant SaaS, be precise about what “this user’s data” means. A user’s data does not include other tenants’ records that happened to be produced in response to that user’s actions (for example, an audit log entry that shows an admin of another organization viewed a shared report). Scope DSAR responses to the requesting user’s own personal data only.

Closing

The pattern that makes GDPR engineering tractable is the data inventory as a first-class code artifact. Everything else (consent versioning, DSAR pipelines, erasure cascades, Article 30 records) derives from it. Start there. Keep it in source control. Review it in PRs when new tables appear. The legal requirements are specific, but the engineering is mostly disciplined data management with audit trails attached.

One thing that is worth saying directly: compliance that only exists in documents is not compliance. Regulators look at what your systems actually do, not what your privacy policy says they do. The code that ships is the compliance posture.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.