Back to Blog

SaaS GDPR Engineering: Data Deletion, Consent Management, and Right-to-Erasure Pipelines

Build GDPR-compliant SaaS infrastructure: implement right-to-erasure pipelines that delete user data across all systems, manage consent with audit trails, handle data subject access requests (DSARs), and anonymize analytics data.

Viprasol Tech Team
October 16, 2026
13 min read

GDPR compliance for SaaS is mostly an engineering problem. The legal requirements (right to erasure, data portability, consent records) translate directly into system design decisions: how you store user data, whether you cascade deletes or anonymize, how you sync deletions to third-party systems, and whether your analytics pipeline retains PII.

The biggest mistake: treating GDPR as an afterthought and then trying to bolt deletion pipelines onto a schema that wasn't designed for them.


Data Inventory First

Before writing code, map every place user data lives:

Primary Database (PostgreSQL):
  - users table (email, name, IP, preferences)
  - audit_logs table (user actions — may need to retain)
  - analytics events (anonymize, don't delete)
  - content created by user (may need to retain for other users)

Object Storage (S3):
  - User avatar images
  - Uploaded files
  - Export files

Cache (Redis):
  - Session data
  - User preferences cache

Analytics (Segment, Mixpanel, Amplitude):
  - Clickstream events with user_id
  - Identity profiles

Email (Resend, SendGrid):
  - Email addresses in contact lists
  - Email delivery history

CRM (HubSpot, Salesforce):
  - Contact records
  - Deal history

Logs (CloudWatch, Datadog):
  - May contain PII (IP addresses, emails in error messages)
  - Typically 30–90 day retention policy handles this

Right-to-Erasure Pipeline

// src/services/gdpr/erasure.service.ts

export type ErasureStrategy = "delete" | "anonymize" | "retain";

interface ErasureStep {
  name: string;
  strategy: ErasureStrategy;
  execute: (userId: string) => Promise<void>;
}

export class ErasureService {
  private readonly steps: ErasureStep[] = [
    // Step 1: Anonymize analytics (can't delete — breaks aggregate metrics)
    {
      name: "anonymize-analytics",
      strategy: "anonymize",
      execute: async (userId) => {
        await db.query(
          `UPDATE events
           SET user_id = NULL,
               anonymous_id = $2,  -- Replace with stable but non-identifying hash
               properties = properties - 'email' - 'name' - 'phone'
           WHERE user_id = $1`,
          [userId, `anon-${hashUserId(userId)}`]
        );
      },
    },

    // Step 2: Delete user-generated content (or anonymize if other users depend on it)
    {
      name: "handle-content",
      strategy: "anonymize",
      execute: async (userId) => {
        // Comments: anonymize (other users see "[Deleted User]")
        await db.query(
          `UPDATE comments
           SET user_id = NULL,
               author_name = '[Deleted User]',
               author_email = NULL
           WHERE user_id = $1`,
          [userId]
        );

        // Private files: delete
        const { rows: files } = await db.query<{ s3_key: string }>(
          "SELECT s3_key FROM user_files WHERE user_id = $1 AND visibility = 'private'",
          [userId]
        );

        for (const file of files) {
          await s3.deleteObject({
            Bucket: process.env.S3_BUCKET!,
            Key: file.s3_key,
          });
        }

        await db.query(
          "DELETE FROM user_files WHERE user_id = $1 AND visibility = 'private'",
          [userId]
        );
      },
    },

    // Step 3: Delete S3 avatar
    {
      name: "delete-avatar",
      strategy: "delete",
      execute: async (userId) => {
        await s3.deleteObject({
          Bucket: process.env.S3_BUCKET!,
          Key: `avatars/${userId}.webp`,
        });
      },
    },

    // Step 4: Revoke all sessions
    {
      name: "revoke-sessions",
      strategy: "delete",
      execute: async (userId) => {
        await redis.del(`session:${userId}:*`); // Delete all session keys
        await db.query(
          "DELETE FROM refresh_tokens WHERE user_id = $1",
          [userId]
        );
      },
    },

    // Step 5: Notify third-party services
    {
      name: "notify-third-parties",
      strategy: "delete",
      execute: async (userId) => {
        const { rows } = await db.query<{ email: string }>(
          "SELECT email FROM users WHERE id = $1",
          [userId]
        );
        if (!rows[0]) return;

        const email = rows[0].email;

        // Delete from email service
        await Promise.allSettled([
          resend.contacts.remove({ email, audienceId: process.env.RESEND_AUDIENCE_ID! }),
          hubspot.crm.contacts.basicApi.archive(email),
          segment.identify({ userId, traits: { $delete: true } }),
        ]);
      },
    },

    // Step 6: Anonymize audit logs (retain for legal compliance, remove PII)
    {
      name: "anonymize-audit-logs",
      strategy: "anonymize",
      execute: async (userId) => {
        await db.query(
          `UPDATE audit_log_entries
           SET user_id = NULL,
               user_email = NULL,
               ip_address = NULL,
               user_agent = NULL
           WHERE user_id = $1`,
          [userId]
        );
      },
    },

    // Step 7: Delete the user record (last — all FK references cleaned first)
    {
      name: "delete-user-record",
      strategy: "delete",
      execute: async (userId) => {
        await db.query("DELETE FROM users WHERE id = $1", [userId]);
      },
    },
  ];

  async executeErasure(userId: string): Promise<ErasureResult> {
    // Log the erasure request for compliance
    const erasureId = await this.createErasureRecord(userId);
    const results: { step: string; success: boolean; error?: string }[] = [];

    for (const step of this.steps) {
      try {
        await step.execute(userId);
        results.push({ step: step.name, success: true });
      } catch (error) {
        // Log failure but continue — partial erasure is better than none
        const message = error instanceof Error ? error.message : String(error);
        results.push({ step: step.name, success: false, error: message });
        console.error(`Erasure step ${step.name} failed for user ${userId}:`, error);
      }
    }

    await this.completeErasureRecord(erasureId, results);

    const allSucceeded = results.every((r) => r.success);
    if (!allSucceeded) {
      // Alert engineering team — incomplete erasure needs manual review
      await alertErasureFailure(userId, erasureId, results);
    }

    return { erasureId, completed: allSucceeded, steps: results };
  }

  private async createErasureRecord(userId: string): Promise<string> {
    const { rows } = await db.query<{ id: string }>(
      `INSERT INTO gdpr_erasure_requests
       (user_id, requested_at, status)
       VALUES ($1, NOW(), 'in_progress')
       RETURNING id`,
      [userId]
    );
    return rows[0].id;
  }
}

šŸš€ SaaS MVP in 8 Weeks — Seriously

We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment — all handled by one senior team.

  • Week 1–2: Architecture design + wireframes
  • Week 3–6: Core features built + tested
  • Week 7–8: Launch-ready on AWS/Vercel with CI/CD
  • Post-launch: Maintenance plans from month 3

Consent Management

-- Consent events — append-only ledger (never update, only insert)
CREATE TABLE consent_events (
  id           UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      UUID         NOT NULL,
  consent_type TEXT         NOT NULL,
  -- Types: 'marketing_email' | 'analytics' | 'functional' | 'personalization'
  action       TEXT         NOT NULL,  -- 'granted' | 'revoked'
  source       TEXT         NOT NULL,  -- 'signup_form' | 'settings_page' | 'api'
  ip_address   INET,
  user_agent   TEXT,
  recorded_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
  -- Store the consent text shown to the user at time of consent
  consent_version TEXT      NOT NULL,
  consent_text    TEXT      NOT NULL
);

-- Current consent state: last action per user per type
CREATE VIEW current_consent AS
SELECT DISTINCT ON (user_id, consent_type)
  user_id,
  consent_type,
  action AS current_state,
  recorded_at AS last_updated,
  consent_version
FROM consent_events
ORDER BY user_id, consent_type, recorded_at DESC;
// src/services/gdpr/consent.service.ts

export type ConsentType = "marketing_email" | "analytics" | "functional" | "personalization";
export type ConsentAction = "granted" | "revoked";

export async function recordConsent(params: {
  userId: string;
  type: ConsentType;
  action: ConsentAction;
  source: string;
  ipAddress: string;
  userAgent: string;
  consentVersion: string;   // Version of your Privacy Policy / cookie banner
  consentText: string;      // Exact text shown to the user
}): Promise<void> {
  await db.query(
    `INSERT INTO consent_events
     (user_id, consent_type, action, source, ip_address, user_agent,
      consent_version, consent_text)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
    [
      params.userId, params.type, params.action, params.source,
      params.ipAddress, params.userAgent,
      params.consentVersion, params.consentText,
    ]
  );

  // If analytics consent revoked: anonymize historical data
  if (params.type === "analytics" && params.action === "revoked") {
    await anonymizeUserAnalytics(params.userId);
  }
}

export async function getUserConsents(userId: string): Promise<
  Record<ConsentType, { granted: boolean; lastUpdated: Date }>
> {
  const { rows } = await db.query(
    "SELECT * FROM current_consent WHERE user_id = $1",
    [userId]
  );

  return Object.fromEntries(
    rows.map((row) => [
      row.consent_type,
      { granted: row.current_state === "granted", lastUpdated: row.last_updated },
    ])
  ) as Record<ConsentType, { granted: boolean; lastUpdated: Date }>;
}

Data Subject Access Request (DSAR)

// src/services/gdpr/dsar.service.ts
// Generate a complete data export for a user

export async function generateDataExport(userId: string): Promise<string> {
  const [user, events, files, consents, subscriptions] = await Promise.all([
    db.query("SELECT * FROM users WHERE id = $1", [userId]),
    db.query(
      "SELECT event_type, occurred_at, properties FROM events WHERE user_id = $1 ORDER BY occurred_at DESC LIMIT 10000",
      [userId]
    ),
    db.query(
      "SELECT filename, created_at, file_size FROM user_files WHERE user_id = $1",
      [userId]
    ),
    db.query(
      "SELECT consent_type, action, recorded_at, consent_version FROM consent_events WHERE user_id = $1 ORDER BY recorded_at DESC",
      [userId]
    ),
    db.query(
      "SELECT plan, status, created_at FROM subscriptions WHERE user_id = $1",
      [userId]
    ),
  ]);

  const exportData = {
    exportedAt: new Date().toISOString(),
    exportVersion: "1.0",
    userData: {
      profile: user.rows[0],
      activityHistory: events.rows,
      files: files.rows,
      consentHistory: consents.rows,
      subscriptions: subscriptions.rows,
    },
  };

  // Upload to a time-limited S3 presigned URL (expires in 7 days)
  const key = `gdpr-exports/${userId}/${Date.now()}.json`;
  await s3.putObject({
    Bucket: process.env.GDPR_EXPORT_BUCKET!,
    Key: key,
    Body: JSON.stringify(exportData, null, 2),
    ContentType: "application/json",
    // Auto-delete after 30 days
    Expires: new Date(Date.now() + 30 * 24 * 3600 * 1000),
  });

  const downloadUrl = await s3.getSignedUrlPromise("getObject", {
    Bucket: process.env.GDPR_EXPORT_BUCKET!,
    Key: key,
    Expires: 7 * 24 * 3600, // URL valid for 7 days
  });

  return downloadUrl;
}

šŸ’” The Difference Between a SaaS Demo and a SaaS Business

Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments — with architecture that does not need to be rewritten at 1,000 users.

  • Multi-tenant PostgreSQL with row-level security
  • Stripe subscriptions, usage billing, annual plans
  • SOC2-ready infrastructure from day one
  • We own zero equity — you own everything

GDPR Engineering Checklist

Data Inventory:
ā–” All PII data stores documented
ā–” Data retention periods defined per table
ā–” Third-party processors listed in DPA

Right to Erasure:
ā–” Deletion pipeline covers all data stores
ā–” Third-party deletion notifications implemented
ā–” Erasure requests logged with completion status
ā–” Partial erasure triggers alert for manual review

Consent:
ā–” Consent stored as append-only event log
ā–” Consent text + version recorded at time of consent
ā–” Analytics disabled when consent revoked
ā–” Consent UI surfaces in settings for self-service update

DSAR:
ā–” Export covers all personal data
ā–” Export delivered within 30 days (legal requirement)
ā–” Export download link time-limited

Data Minimization:
ā–” IP addresses hashed or truncated in analytics
ā–” Logs don't contain raw emails or names
ā–” Telemetry uses anonymous IDs, not emails

See Also


Working With Viprasol

GDPR compliance is easier to build correctly from the start than to retrofit into an existing system. We design deletion pipelines, consent management schemas, DSAR automation, and data minimization patterns for SaaS products serving EU users — so compliance is a system property, not a manual process.

SaaS compliance engineering → | Talk to our engineers →

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours — fast.

Free consultation • No commitment • Response within 24 hours

Viprasol Ā· AI Agent Systems

Add AI automation to your SaaS product?

Viprasol builds custom AI agent crews that plug into any SaaS workflow — automating repetitive tasks, qualifying leads, and responding across every channel your customers use.