GDPR Privacy Engineering skill for Claude Code
Data maps, consent, and deletion you can actually prove. Claude builds the machinery privacy rules depend on: a data map generated from your schema and integrations, purpose and retention tags next to the fields, append-only consent records that honor Global Privacy Control, and a DSAR handler registry that exports and erases across the database, processors, analytics, and backups. Legal judgments stay with counsel; this is engineering, not legal advice.
- name
gdpr-privacy - size262 lines · ≈3,200 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Implements privacy engineering for GDPR and CCPA/CPRA in application code - a data map built from the schema, purpose and lawful-basis tags, consent records and cookie gating with Global Privacy Control, retention jobs, DSAR access, export, correction, and deletion flows that reach processors, logs, and backups, pseudonymization, and breach-ready inventories. Use when building a privacy or account-deletion feature, answering "where is personal data stored", adding a cookie banner or consent mode, setting retention, or preparing for a privacy review. Engineering guidance, not legal advice. Try asking
- Build a data map of where we store personal data in this repo and our third parties
- Implement account deletion that also removes the user from Stripe, PostHog, and HubSpot
- Our cookie banner loads Google Analytics before consent; fix it and honor GPC
Use it when
- Building account deletion, data export, or a privacy settings page
- Someone asks where personal data is stored or who it is shared with
- Adding or fixing a cookie banner, consent mode, or opt-out of sale or sharing
- Setting retention periods and cleanup jobs for personal data
Skip it when
- You need SOC 2 controls and audit evidence rather than privacy rights - use soc2-readiness instead
- The task is redacting personal data from documents or PDFs - use document-redaction instead
How to check the result
A seeded test subject is fully exported and then erased across every mapped system with only documented retentions left, and with analytics declined or Sec-GPC set no tracking requests leave the browser.
The common mistake it prevents
Treating a soft-delete flag or a hashed email as erasure or anonymization; both still hold personal data, and the person's records live on in processors and backups.
Done well means: there is a data map that matches the actual schema, every personal-data field has a purpose and a retention period, consent is recorded with what was shown and when, a data subject request can be fulfilled end to end in hours (not weeks) including processors, and deletion is provable. The legal decisions (lawful basis, whether a DPIA is needed, what counts as “sale”) belong to counsel; this skill makes the system able to carry them out.
This is engineering guidance, not legal advice. Regulations, guidance, and enforcement change and vary by jurisdiction. State this to the user when delivering privacy work, and route lawful-basis, cross-border transfer, and notice-wording decisions to their privacy counsel or DPO.
Ground rules
- Map before you build. No consent banner or deletion endpoint until you know where personal data lives, including logs, analytics, search indexes, caches, files, and third parties.
- Minimize first: the cheapest field to protect is the one you never collect. Challenge every new personal-data column in review (“what is it for, and when do we delete it?”).
- Purpose limitation in code: tag fields and events with purposes; do not reuse support data for marketing without a basis.
- Deletion must be real or explainably retained (legal hold, tax records), never a silent
is_deleted = truethat keeps everything forever. - Consent is per purpose, recorded, versioned, and as easy to withdraw as to give.
- Treat identity verification as part of every DSAR; an export sent to an impostor is a breach.
Workflow
- [ ] 1. Build the data map from schema and integrations
- [ ] 2. Tag purpose, lawful basis (from counsel), retention, and processors per field
- [ ] 3. Consent and opt-out: record, gate, honor GPC
- [ ] 4. Retention: automated deletion or anonymization jobs
- [ ] 5. DSAR flows: verify, access/export, correct, delete, track deadlines
- [ ] 6. Propagate to processors, logs, analytics, backups
- [ ] 7. Verify with a seeded test subject; keep evidence
1. Data map
Start from the schema, then add what schemas miss:
python3 scripts/pii_column_scan.py db/migrations prisma/schema.prisma app/models.py --format md > data-map.md
python3 scripts/pii_column_scan.py . --format csv > data-map.csv
python3 scripts/pii_column_scan.py . --fail-on-special # exit 1 if special-category columns exist (CI gate)
The script flags columns by name (contact, identity, government ID, financial, credentials, location, online identifiers, special category, free text) in SQL DDL, Prisma, Django or SQLAlchemy models, and TypeORM entities, and emits a table with TODO columns for purpose, basis, retention, and processors. It is a heuristic; then search for what it cannot see:
# Personal data leaving the system
grep -rnE "posthog|mixpanel|segment|amplitude|gtag|fbq|intercom|hubspot|sentry|datadog|mailchimp|sendgrid|resend|stripe" --include=*.{ts,tsx,js,py,rb,go} . | cut -d: -f1 | sort -u
# JSON blobs and free text that hold personal data without a column name
grep -rnE "JSONB?|jsonb|metadata|payload|extra_data" db/ --include=*.sql | head
# Files: avatars, uploads, exports
grep -rnE "s3|bucket|upload|presign" --include=*.{ts,py,go,rb} . | cut -d: -f1 | sort -u
Data map row format (also serves as the engineering input to the Article 30 record):
| System / table.field | Category | Purpose | Lawful basis | Retention | Processors | Deletion method |
|---|---|---|---|---|---|---|
| users.email | contact | account, transactional email | contract | life of account + 30 d | Resend, Postgres host | hard delete |
| events.ip | online identifier | fraud prevention | legitimate interests | 90 d | Datadog | TTL drop |
| orders.billing_address | contact | invoicing | legal obligation | 10 y (tax, confirm locally) | Stripe | retain, restrict access |
2. Tags in code
Put the map next to the schema so it stays current:
# models.py (Django) - privacy metadata colocated with fields
class User(models.Model):
email = models.EmailField(unique=True) # privacy: contact | purpose=account | retention=account+30d
marketing_opt_in = models.BooleanField(default=False)
PRIVACY = {
"User.email": {"category": "contact", "purposes": ["account"], "export": True, "erase": "delete"},
"Order.billing_address": {"category": "contact", "purposes": ["invoicing"], "export": True, "erase": "retain_legal"},
}
Add a CI check that fails when a new model field matching personal-data patterns has no
PRIVACY entry (the scanner’s CSV output diffed against the registry works).
3. Consent and opt-outs
| Situation | Requirement to implement (confirm with counsel) |
|---|---|
| EU/UK visitors, non-essential cookies or trackers | Prior opt-in before setting them; reject as easy as accept; no pre-ticked boxes |
| Analytics needed for product | Cookieless or server-side with IP truncation, or gate behind consent |
| California residents, ads or cross-context sharing | “Do Not Sell or Share” opt-out; honor Global Privacy Control (Sec-GPC: 1 / navigator.globalPrivacyControl) as an opt-out |
| Marketing email | Separate, unticked consent; one-click unsubscribe |
| Sensitive data (health, precise location) | Explicit consent or a specific basis; CPRA “limit use” right |
Consent record (append-only, one row per change):
CREATE TABLE consent_events (
id BIGSERIAL PRIMARY KEY,
subject_id UUID, -- null for anonymous visitor, then linked on login
anon_id TEXT, -- first-party visitor ID
purpose TEXT NOT NULL, -- 'analytics' | 'ads' | 'marketing_email' | ...
granted BOOLEAN NOT NULL,
source TEXT NOT NULL, -- 'banner' | 'settings' | 'gpc' | 'api'
notice_version TEXT NOT NULL, -- hash or version of the text shown
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Gate loading, not just firing: tag scripts must not load until the purpose is granted.
const gpc = (navigator as any).globalPrivacyControl === true;
const consent = readConsent(); // from first-party cookie set by your banner
if (gpc) recordConsent({ purpose: "ads", granted: false, source: "gpc" });
if (consent.analytics && !gpc) loadScript("/analytics.js");
if (consent.ads && !gpc) loadScript("https://ads.example/tag.js");
On the server, read Sec-GPC: 1 and suppress sharing with ad partners for that request and
the linked account. If using Google tags, Consent Mode v2 signals (ad_storage,
analytics_storage, ad_user_data, ad_personalization) must default to denied for
regions that require opt-in.
4. Retention
- Every table with personal data gets a retention rule in the data map and a scheduled job.
- Prefer partition drops or TTL indexes for high-volume data (events, logs); batched deletes for the rest.
-- nightly, batched to avoid long locks
DELETE FROM login_events
WHERE id IN (SELECT id FROM login_events WHERE created_at < now() - interval '90 days' LIMIT 10000);
- Anonymize instead of delete when aggregates must survive: drop direct identifiers and coarsen quasi-identifiers (birth date to year, location to region). Hashing an email is pseudonymization, not anonymization; it is still personal data.
- Before enabling a new deletion job in production, run it in report-only mode and get user confirmation on the row counts; deletion is irreversible.
5. Data subject requests
| Right | GDPR | CCPA/CPRA | Implementation |
|---|---|---|---|
| Access / know | Art. 15 | Yes | Export of all data linked to the subject plus purposes, recipients, retention |
| Portability | Art. 20 | Covered by access | Machine-readable (JSON/CSV in a zip) |
| Rectification / correct | Art. 16 | Yes (CPRA) | Self-service edit where possible |
| Erasure / delete | Art. 17 | Yes | Delete job across systems with exceptions log |
| Object / opt out | Art. 21 | Opt out of sale/sharing | Purpose flags, suppression lists |
| Deadline | One month, extendable for complex cases | 45 days, extendable | Track due date per request |
DSAR pipeline:
- Intake (settings page, email, form) creates a
privacy_requestsrow with type, received date, due date, status. - Verify identity: signed-in session with recent re-auth is best; otherwise verify control of the account email. Never accept identity documents by email into a general inbox.
- Resolve the subject across systems: user ID, emails (including historical), customer IDs at processors, anonymous IDs linked by consent records.
- Execute: run export or erase handlers registered per system (below), collect results.
- Deliver: export via an expiring, authenticated download link (24-72 h), never as an email attachment.
- Close with an evidence record: what ran, row counts, exceptions (legal retention), dates.
Handler registry so new systems cannot be forgotten:
DSAR_HANDLERS: dict[str, "Handler"] = {}
def dsar_handler(system: str):
def wrap(cls):
DSAR_HANDLERS[system] = cls()
return cls
return wrap
@dsar_handler("postgres.users")
class UsersHandler:
def export(self, subject) -> dict: ...
def erase(self, subject) -> dict: # returns {"deleted": n, "retained": [{"table": ..., "reason": "tax law"}]}
...
@dsar_handler("stripe")
class StripeHandler:
def export(self, subject) -> dict: ...
def erase(self, subject) -> dict: ... # delete customer or redact per provider API; record request ID
Erase ordering: revoke sessions and API keys, stop processing (suppress emails), delete or redact in primary DB, call processor deletion APIs, purge search indexes and caches, then queue analytics and log deletions. Keep a minimal suppression record (hashed email) so the person is not re-imported or re-mailed, and document why it is retained.
6. Logs, analytics, backups
- Logs: keep personal data out at write time (see security-logging); short retention for what remains. Searching logs per DSAR does not scale.
- Analytics tools: use their user-deletion APIs keyed by your distinct ID; record request IDs.
- Backups: you usually cannot edit them. Keep backup retention bounded (for example 30-35 days), maintain a deletion ledger, and re-apply deletions after any restore before the data is used.
- Crypto-shredding: per-subject encryption keys for high-volume immutable stores let you erase by destroying the key.
Gotchas
- Soft delete is not erasure. If you soft-delete for undo, hard-delete after a short window.
- Foreign keys with
ON DELETE CASCADEcan delete business records you must keep (invoices); decouple them (null the user reference, keep the record) deliberately. - Personal data hides in free text: support tickets, notes, file names, URLs, and search queries.
- IP addresses, device IDs, and cookie IDs are personal data under GDPR.
- Consent banners that load tags before the choice, or that make reject harder than accept, are a frequent enforcement target.
- Processors: a deletion request to your own DB without propagating to the CRM, email tool, and data warehouse is incomplete.
- Breach clock: GDPR expects notification to the supervisory authority within 72 hours of becoming aware. The data map and security logs are what make that assessment possible in time.
- Exports must not include other people’s data (messages from other users, shared documents they own); redact third parties.
Verification
- Seed a test subject with data in every mapped system (DB, files, search, analytics, CRM, email provider in test mode).
- Run access: the export contains every mapped field and nothing about other users.
- Run erasure: query every system; only documented retained records remain, each with a reason in the evidence record.
- Consent: with analytics declined, the network panel shows no analytics requests or
cookies; with
Sec-GPC: 1, ad partners receive nothing. - Rerun
pii_column_scan.pyin CI; new flagged columns without a registry entry fail.
Output contract
## Privacy engineering: <app>
Disclaimer: engineering implementation, not legal advice; lawful bases supplied by <counsel/DPO>.
### Data map
<table: system.field | category | purpose | basis | retention | processors | deletion>
### Consent
Purposes: analytics, ads, marketing_email; GPC honored client and server; records in consent_events
### Retention jobs
<job, table, rule, schedule, last run row count>
### DSAR
Handlers: postgres.users, s3.uploads, stripe, posthog, hubspot (5/5 systems in map)
Test subject run: access OK, erase OK, retained: orders (tax, 10 y)
### Open items for counsel
- <basis for X, transfer mechanism for Y> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/pii_column_scan.py
#!/usr/bin/env python3
"""Scan schema files for columns that look like personal data and print a draft data map.
Supports SQL DDL (CREATE TABLE / ALTER TABLE ADD COLUMN), Prisma schema models,
Django models, and SQLAlchemy/TypeORM-style field declarations, using name heuristics.
The output is a starting inventory for human review, not a compliance determination.
Usage:
python3 scripts/pii_column_scan.py <path> [<path> ...] [--format md|csv|json] [--fail-on-special]
Exit codes: 0 = scan completed, 1 = --fail-on-special and special-category data found,
2 = usage error or no schema files found.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import sys
# (category, risk, regex on normalized column name)
RULES: list[tuple[str, str, re.Pattern[str]]] = [
("special_category", "high", re.compile(r"(health|medical|diagnos|disab|religio|ethnic|race|sexual|orientation|gender_identity|biometric|fingerprint|face_?(id|print)|genetic|union_member|political|pregnan|criminal|conviction)")),
("government_id", "high", re.compile(r"(ssn|social_security|national_id|passport|tax_?id|tin$|nin$|driver_?licen[cs]e|nric|aadhaar|sin_number)")),
("financial", "high", re.compile(r"(card_?number|pan$|cc_num|iban|bank_?account|account_number|routing|sort_code|cvv|cvc|salary|income|credit_score)")),
("credential", "high", re.compile(r"(password|passwd|pwd_hash|secret|api_?key|access_?token|refresh_?token|otp|mfa_secret|totp)")),
("precise_location", "high", re.compile(r"(latitude|longitude|^lat$|^lng$|^lon$|geo_?point|gps|coordinates)")),
("contact", "medium", re.compile(r"(e_?mail|phone|mobile|msisdn|fax|street|address|postal|zip_?code|postcode)")),
("identity", "medium", re.compile(r"(first_?name|last_?name|full_?name|surname|given_name|family_name|maiden|^name$|display_name|birth|dob$|date_of_birth|age$|gender|sex$|nationality|photo|avatar|signature)")),
("online_identifier", "medium", re.compile(r"(ip_?addr|^ip$|_ip$|user_?agent|device_?id|idfa|gaid|advertising_id|cookie_?id|fingerprint_hash|session_id|mac_addr)")),
("coarse_location", "low", re.compile(r"(city|region|country|timezone|locale)")),
("free_text", "medium", re.compile(r"(notes?$|comment|message|bio$|description|feedback|transcript|body$)")),
]
SQL_TABLE = re.compile(r"create\s+table\s+(?:if\s+not\s+exists\s+)?[`\"\[]?([\w.]+)[`\"\]]?\s*\((.*?)\)\s*;", re.I | re.S)
SQL_ALTER = re.compile(r"alter\s+table\s+(?:only\s+)?[`\"\[]?([\w.]+)[`\"\]]?\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?[`\"\[]?(\w+)", re.I)
SQL_COL = re.compile(r"^\s*[`\"\[]?(\w+)[`\"\]]?\s+\w+")
SQL_SKIP = {"primary", "foreign", "constraint", "unique", "check", "key", "index", "exclude"}
PRISMA_MODEL = re.compile(r"^model\s+(\w+)\s*\{(.*?)^\}", re.M | re.S)
PRISMA_FIELD = re.compile(r"^\s*(\w+)\s+[A-Z]\w*[\[\]?]*", re.M)
DJANGO_CLASS = re.compile(r"^class\s+(\w+)\((?:[\w.]*Model|[\w.]*AbstractUser|[\w.]*Base)\b.*?\):\n((?:[ \t]+.*\n|\n)*)", re.M)
DJANGO_FIELD = re.compile(r"^\s+(\w+)\s*(?::\s*[\w\[\], .|]+)?=\s*(?:models\.\w+|Column|mapped_column|fields\.\w+)\(", re.M)
TS_FIELD = re.compile(r"@Column\([^)]*\)\s*(\w+)\s*[!?]?:", re.S)
TS_CLASS = re.compile(r"@Entity\([^)]*\)\s*export\s+class\s+(\w+)")
EXTS = {".sql", ".prisma", ".py", ".ts"}
def normalize(name: str) -> str:
s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name)
return s.lower()
def classify(column: str) -> tuple[str, str] | None:
n = normalize(column)
if n in {"id", "created_at", "updated_at", "deleted_at"} or n.endswith("_id") and not re.search(r"(device|cookie|advertising|session|national|tax)", n):
return None
for category, risk, rx in RULES:
if rx.search(n):
return category, risk
return None
def split_top_level(body: str) -> list[str]:
"""Split a CREATE TABLE body on commas that are not inside parentheses."""
parts, depth, cur = [], 0, []
for ch in body:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == "," and depth == 0:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
parts.append("".join(cur))
return [p.strip() for p in parts if p.strip()]
def parse_file(path: str) -> list[tuple[str, str]]:
"""Return (table, column) pairs found in a file."""
try:
text = open(path, encoding="utf-8", errors="replace").read()
except OSError as exc:
print(f"warn: cannot read {path}: {exc}", file=sys.stderr)
return []
pairs: list[tuple[str, str]] = []
ext = os.path.splitext(path)[1].lower()
if ext == ".sql":
for table, body in SQL_TABLE.findall(text):
for part in split_top_level(body):
m = SQL_COL.match(part)
if m and m.group(1).lower() not in SQL_SKIP:
pairs.append((table, m.group(1)))
pairs.extend(SQL_ALTER.findall(text))
elif ext == ".prisma":
for model, body in PRISMA_MODEL.findall(text):
for field in PRISMA_FIELD.findall(body):
if not field.startswith("@"):
pairs.append((model, field))
elif ext == ".py":
for cls, body in DJANGO_CLASS.findall(text):
for field in DJANGO_FIELD.findall(body):
pairs.append((cls, field))
elif ext == ".ts":
m = TS_CLASS.search(text)
if m:
for field in TS_FIELD.findall(text):
pairs.append((m.group(1), field))
return pairs
def collect(paths: list[str]) -> list[str]:
files: list[str] = []
for p in paths:
if os.path.isdir(p):
for root, dirs, names in os.walk(p):
dirs[:] = [d for d in dirs if d not in {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build"}]
files.extend(os.path.join(root, n) for n in names if os.path.splitext(n)[1].lower() in EXTS)
elif os.path.isfile(p):
files.append(p)
else:
print(f"warn: {p} does not exist", file=sys.stderr)
return sorted(set(files))
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("paths", nargs="+")
ap.add_argument("--format", choices=["md", "csv", "json"], default="md")
ap.add_argument("--fail-on-special", action="store_true", help="exit 1 if special-category columns exist")
args = ap.parse_args()
files = collect(args.paths)
if not files:
print("error: no .sql, .prisma, .py, or .ts files found", file=sys.stderr)
return 2
rows = []
seen = set()
scanned_columns = 0
for f in files:
for table, col in parse_file(f):
scanned_columns += 1
hit = classify(col)
key = (table, col)
if hit and key not in seen:
seen.add(key)
rows.append({"table": table, "column": col, "category": hit[0], "risk": hit[1], "file": os.path.relpath(f)})
order = {"high": 0, "medium": 1, "low": 2}
rows.sort(key=lambda r: (order[r["risk"]], r["table"], r["column"]))
if args.format == "json":
print(json.dumps({"files": len(files), "columns_scanned": scanned_columns, "findings": rows}, indent=2))
elif args.format == "csv":
w = csv.DictWriter(sys.stdout, fieldnames=["table", "column", "category", "risk", "file"])
w.writeheader()
w.writerows(rows)
else:
print(f"# Draft data map ({len(files)} files, {scanned_columns} columns scanned, {len(rows)} flagged)\n")
print("| Table | Column | Category | Risk | Purpose | Lawful basis | Retention | Processors | File |")
print("|---|---|---|---|---|---|---|---|---|")
for r in rows:
print(f"| {r['table']} | {r['column']} | {r['category']} | {r['risk']} | TODO | TODO | TODO | TODO | {r['file']} |")
print("\nHeuristic scan by column name only. Review every row, add missed fields (JSON blobs, free text, files), and fill the TODO columns.")
special = [r for r in rows if r["category"] == "special_category"]
if args.fail_on_special and special:
print(f"\nspecial-category columns found: {len(special)}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.