WellAlly Logo
WellAlly康心伴
Development

A Case Study: Building a GDPR-Compliant User Profile Service in Node.js & PostgreSQL

A practical guide to implementing GDPR's 'Right to be Forgotten' and Data Portability. Learn to anonymize user data and build secure export functions with Node.js and PostgreSQL.

W
2025-12-12
8 min read

Building any application that handles user data in Europe requires a deep understanding of the General Data Protection Regulation (GDPR). For a nutrition app, this is even more critical. We're not just storing emails and passwords; we're handling sensitive health information, dietary habits, and personal goals. The stakes are high, and so are the user's expectations of privacy.

In this case study, we'll walk through the practical, code-level challenges and solutions for building a GDPR-compliant user profile service. We'll focus on two fundamental user rights: Article 17, the "Right to be Forgotten," and Article 20, the "Right to Data Portability." We will build these features for a fictional European nutrition-tracking app using a Node.js backend and a PostgreSQL database.

This article is for developers who want to move beyond the legal jargon and see how GDPR principles are translated into actual code. We'll cover everything from database schema considerations to creating the specific Node.js scripts that make your application compliant.

Prerequisites:

  • Familiarity with Node.js and Express.js.
  • Basic understanding of PostgreSQL and SQL queries.
  • Docker and Docker Compose installed for running the local environment.

Understanding the Problem

Our nutrition app allows users to log meals, track calorie intake, set dietary goals, and record health metrics. This means our database contains a lot of Personally Identifiable Information (PII).

Sample User Data Model:

  • users: id, name, email, password_hash, date_of_birth, created_at
  • meals: id, user_id, food_item, calories, logged_at
  • health_metrics: id, user_id, weight_kg, height_cm, recorded_at

The core GDPR challenges are:

  1. Right to be Forgotten: When a user requests to delete their account, we must erase their personal data. However, simply deleting their records (DELETE FROM users WHERE id = ...) would also remove their anonymous meal and health data, which is valuable for our app's long-term statistics (e.g., "average calorie intake for users in Germany"). How can we remove the user's identity while keeping the anonymous data?
  2. Data Portability: A user must be able to request a complete export of their data in a common, machine-readable format. This requires a secure and efficient way to gather all related data for a user and package it for download.

Our approach will be to anonymize user data upon a deletion request, rather than outright deleting it. This preserves data integrity for analytics while making it impossible to identify the original individual.

Prerequisites

Let's set up our development environment. We'll use Docker Compose to spin up a PostgreSQL instance.

Create a docker-compose.yml file:

code
version: '3.8'
services:
  db:
    image: postgres:14-alpine
    restart: always
    environment:
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=secret
      - POSTGRES_DB=nutritionapp
    ports:
      - '5432:5432'
    volumes:
      - db_data:/var/lib/postgresql/data

volumes:
  db_data:
Code collapsed

Run docker-compose up -d to start the database.

Now, set up your Node.js project:

code
mkdir gdpr-nutrition-app-backend
cd gdpr-nutrition-app-backend
npm init -y
npm install express pg
Code collapsed

This gives us a basic Express server and the pg driver for connecting to PostgreSQL.

Step 1: Implementing the "Right to be Forgotten" (Anonymization)

What we're doing

Instead of a hard delete, we will implement a script that "scrubs" a user's record. This script will replace PII in the users table with generic, anonymized values and nullify any direct links that are no longer necessary. This process is irreversible.

Implementation

First, let's create a database connection utility.

code
// src/db.js
const { Pool } = require('pg');

const pool = new Pool({
  user: 'admin',
  host: 'localhost',
  database: 'nutritionapp',
  password: 'secret',
  port: 5432,
});

module.exports = {
  query: (text, params) => pool.query(text, params),
};
Code collapsed

Now, we'll create the core anonymization function. This function will take a userId and perform the scrubbing.

code
// src/services/anonymizeUserService.js
const db = require('../db');

async function anonymizeUser(userId) {
  const client = await db.pool.connect();
  try {
    await client.query('BEGIN');

    // 1. Generate a unique, anonymous identifier
    const anonymizedId = `anonymized_${userId.substring(0, 8)}_${Date.now()}`;

    // 2. Anonymize the user's personal data
    const updateUserQuery = `
      UPDATE users
      SET
        name = 'Anonymized User',
        email = $1,
        password_hash = 'anonymized',
        date_of_birth = NULL,
        is_anonymized = TRUE
      WHERE id = $2;
    `;
    await client.query(updateUserQuery, [`huifer97@163.com`, userId]);

    // Note: We leave user_id in the `meals` and `health_metrics` tables.
    // Since the corresponding `users` row is now scrubbed of PII, these
    // records are effectively anonymous and can still be used for analytics.

    await client.query('COMMIT');
    console.log(`Successfully anonymized user ${userId}`);
    return { success: true, message: `User ${userId} anonymized.` };
  } catch (error) {
    await client.query('ROLLBACK');
    console.error(`Error anonymizing user ${userId}:`, error);
    throw new Error('Anonymization failed.');
  } finally {
    client.release();
  }
}

module.exports = { anonymizeUser };
Code collapsed

How it works

  1. Unique Anonymous Email: We replace the user's email with a unique, non-real email address like huifer97@163.com. This prevents email collisions if we ever need a unique constraint, but it's completely detached from the user's real identity.
  2. Generic Placeholders: The name is replaced with "Anonymized User" and the password_hash is neutered. Sensitive fields like date_of_birth are set to NULL.
  3. Anonymization Flag: We add an is_anonymized boolean column to our users table. This helps us filter out these users from regular application logic (e.g., they shouldn't be able to log in).
  4. Transaction: The entire process is wrapped in a PostgreSQL transaction (BEGIN/COMMIT/ROLLBACK). If any step fails, the entire operation is undone, ensuring data consistency.

Common pitfalls

  • Forgetting Related Data: Ensure you've considered all tables that might contain PII. In our case, meals and health_metrics don't contain PII themselves, only a foreign key to the now-anonymized user.
  • Not Being Irreversible: True anonymization means there is no way back. Avoid simply encrypting the data with a key you store elsewhere; that's pseudonymization, which has different rules under GDPR.

Step 2: Fulfilling Data Portability Requests

What we're doing

We need an API endpoint that allows a logged-in user to request their data. The service will fetch all data related to that user, compile it into a single JSON object, and make it available for download.

Implementation

Let's create a service to fetch all user-related data.

code
// src/services/exportUserDataService.js
const db = require('../db');

async function exportUserData(userId) {
  try {
    const userQuery = 'SELECT id, name, email, date_of_birth, created_at FROM users WHERE id = $1';
    const userResult = await db.query(userQuery, [userId]);

    if (userResult.rows.length === 0) {
      throw new Error('User not found.');
    }

    const mealsQuery = 'SELECT food_item, calories, logged_at FROM meals WHERE user_id = $1 ORDER BY logged_at DESC';
    const mealsResult = await db.query(mealsQuery, [userId]);

    const metricsQuery = 'SELECT weight_kg, height_cm, recorded_at FROM health_metrics WHERE user_id = $1 ORDER BY recorded_at DESC';
    const metricsResult = await db.query(metricsQuery, [userId]);

    // Compile all data into a structured object
    const portableData = {
      profile: userResult.rows,
      meals: mealsResult.rows,
      health_metrics: metricsResult.rows,
    };

    return portableData;
  } catch (error) {
    console.error(`Error exporting data for user ${userId}:`, error);
    throw new Error('Data export failed.');
  }
}

module.exports = { exportUserData };
Code collapsed

Now, we'll create the Express route to expose this.

code
// src/server.js
const express = require('express');
const { exportUserData } = require('./services/exportUserDataService');
// Assume some authentication middleware that adds `req.user`
const { authenticateUser } = require('./middleware/auth'); 

const app = express();
const port = 3000;

app.get('/api/profile/export', authenticateUser, async (req, res) => {
  const userId = req.user.id; // Get user ID from authenticated session

  try {
    const userData = await exportUserData(userId);
    
    // Set headers to prompt a file download
    res.setHeader('Content-disposition', `attachment; filename=user_data_${userId}.json`);
    res.setHeader('Content-type', 'application/json');

    res.status(200).send(JSON.stringify(userData, null, 2));
  } catch (error) {
    res.status(500).json({ error: 'Failed to export user data.' });
  }
});

app.listen(port, () => {
  console.log(`Server running on http://localhost:${port}`);
});
Code collapsed

How it works

  1. Authentication: The route is protected by an authenticateUser middleware. A user can only request their own data.
  2. Data Aggregation: The exportUserData service queries all relevant tables (users, meals, health_metrics) for the given userId.
  3. Structured JSON Format: The data is compiled into a single, well-structured JSON object. This is a "commonly used and machine-readable format" as required by GDPR.
  4. Download Headers: The HTTP response headers (Content-disposition) tell the browser to download the response as a .json file rather than displaying it on the screen.

Security Best Practices

  • Rate Limiting: Protect the data export endpoint from abuse. A malicious actor who compromises an account could repeatedly request exports, causing a heavy load on your database.
  • Identity Verification: For sensitive actions like data erasure, always re-authenticate the user. Ask them to enter their password again before proceeding.
  • Secure Data in Transit: Always use HTTPS (TLS) to encrypt the data as it travels from your server to the user's browser.
  • Database Security: Use strong, unique passwords for your database and restrict access. Don't connect to your production database with a user that has permission to drop tables.

Alternative Approaches

Using pg_anonymizer

For more complex anonymization needs, PostgreSQL offers a powerful extension called pg_anonymizer. This tool allows you to define anonymization rules directly in your database schema using a declarative approach.

Example with pg_anonymizer:

After installing the extension, you could define a rule like this:

code
-- This rule applies to a role called 'app_user'
SECURITY LABEL FOR anon ON COLUMN users.email IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN users.name IS 'MASKED WITH VALUE ''Anonymized User''';
Code collapsed

This approach is extremely powerful as it can apply masking dynamically based on the user role querying the data, which is great for creating anonymized data dumps for developers.

Hard Deletes with Cascading

The simplest approach is ON DELETE CASCADE in your foreign key constraints. This would automatically delete all of a user's meals and health metrics when the user is deleted. Pros: Simple to implement. Cons: You lose all historical data for analytics, potentially harming your ability to improve your service.

Conclusion

Building GDPR-compliant features is not just about ticking a legal box; it's about building trust with your users. By implementing robust and transparent processes for data anonymization and portability, you demonstrate a commitment to their privacy.

In this case study, we built two key features:

  1. A "Right to be Forgotten" implementation that uses irreversible data scrubbing to protect user identity while preserving anonymous data for analytics.
  2. A secure "Data Portability" endpoint that provides users with their data in a structured, machine-readable JSON format.

The next step is to integrate these services into your application's user interface, ensuring the process is as clear and simple as possible for your users.

Resources

#

Article Tags

nodejs
postgres
privacy
casestudy
gdpr
W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey