GDPR Compliance Checklist for Feature Flags

This how-to is part of Building Audit Trails for Compliance. The most common GDPR failure in flag systems is a consent withdrawal that invalidates the legal basis for targeting but does not flush the cached evaluation context — the user keeps receiving the targeted experience for minutes or hours. This checklist walks through detection, containment, and long-term architectural hardening, in that order.

GDPR compliance layers for feature flag evaluation Four layers — context sanitization, consent gate, audit trail, and retention enforcement — stack between the raw user request and the flag evaluation result. Raw Context email, IP, device PII Sanitizer hash + minimize Consent Gate check before eval Flag Evaluation variant resolved Audit Trail + Retention TTL applies to all stages
GDPR compliance requires sanitization and consent gating before evaluation reaches the rule engine; the audit trail and retention TTL span the full pipeline.

Problem Statement

Feature flag targeting attributes — user ID, email, IP address, device fingerprint, behavioral segment — are personal data under GDPR Article 4. Storing them in evaluation context or audit logs without a lawful basis, hashing them inadequately, or failing to invalidate them after a consent withdrawal are each a compliance gap. The legal consequence is a fine; the operational consequence is a post-incident remediation that touches every log store the data touched.

Prerequisites

Step-by-Step Checklist

Step 1 — Scan evaluation context for raw PII

Before changing any architecture, prove where PII currently lives. Run a pattern scan against evaluation logs and SDK context payloads:

# Scan flag-evaluation logs for raw PII field names
grep -rE '"(email|phone|ip_addr|ip_address|device_id|ssn|dob)"' \
  /var/log/flag-evaluation/ \
  | jq -r '.flag_key + " → " + keys[]'

Any hit is a compliance gap. Record each affected flag key, the field name, and the environment. This inventory drives the remediation in the next steps.

Cross-link: if your evaluation context flows through a distributed cache, the same PII appears in cached entries — see distributed caching for flag evaluations for cache invalidation on consent change.

Step 2 — Hash or exclude PII before SDK initialization

Apply a one-way HMAC hash to all personal attributes before they enter the evaluation context. Use a stable secret so the same input always produces the same output (needed for consistent bucketing), but rotate the secret annually so old hashes cannot be reversed:

import { createHmac } from 'crypto';

// compliance.context.js — sanitize before passing to OpenFeature
const HMAC_SECRET = process.env.PII_HMAC_SECRET; // from secrets manager

export const sanitizeEvalContext = (rawContext) => ({
  // stable pseudonymous identifier — consistent bucketing without storing email
  targetingKey: createHmac('sha256', HMAC_SECRET)
    .update(rawContext.email)
    .digest('hex'),
  // non-personal attributes pass through unchanged
  plan:     rawContext.plan,
  region:   rawContext.region,
  // consent state is passed as a boolean, never the CMP record
  consent:  rawContext.consentGiven,
  _meta: { sanitized: true, sanitizedAt: Date.now() }
});

Any flag that uses personal-data attributes for targeting must check consent before evaluating. Treat consent as a prerequisite flag — if compliance.consent.tracking-active is off for a user, fall back to the anonymous default variant:

import { OpenFeature } from '@openfeature/server-sdk';

const client = OpenFeature.getClient();

async function evaluateWithConsentGate(
  flagKey: string,
  ctx: EvaluationContext,
  defaultVariant: string
): Promise<string> {
  // Check consent prerequisite first
  const consentActive = await client.getBooleanValue(
    'compliance.consent.tracking-active', false, ctx
  );
  if (!consentActive) {
    // No lawful basis — return default without targeting
    return defaultVariant;
  }
  return client.getStringValue(flagKey, defaultVariant, ctx);
}

Validate in CI that every flag touching personal attributes declares a consent prerequisite:

# .github/workflows/flag-gdpr-validate.yml
- name: validate-gdpr-prerequisites
  run: |
    npx ajv-cli validate \
      --spec draft7 \
      -s ./schemas/gdpr-flag.json \
      -d './flags/*.json'

The gdpr-flag.json schema requires a prerequisites array containing "compliance.consent.tracking-active" on any flag with data-classification: personal.

A consent withdrawal must propagate to every caching layer within the latency budget specified in your GDPR DPIA (typically seconds, not minutes). Wire your CMP’s consent-revoked event to a cache invalidation call:

# consent_webhook.py — triggered by CMP on consent withdrawal
import hashlib, hmac, os, httpx

HMAC_SECRET = os.environ["PII_HMAC_SECRET"].encode()

def on_consent_revoked(user_email: str) -> None:
    user_hash = hmac.new(HMAC_SECRET, user_email.encode(), hashlib.sha256).hexdigest()
    # Purge the user's cached evaluation context from the flag cache
    httpx.post(
        "https://flag-cache.internal/v1/invalidate",
        json={"targeting_key": user_hash},
        headers={"Authorization": f"Bearer {os.environ['CACHE_API_TOKEN']}"},
        timeout=5.0
    )

Step 5 — Enforce retention TTLs on evaluation logs and audit events

Masking PII in the evaluation context upstream means logs carry hashed identifiers, not raw personal data — but they are still subject to data minimization. Evaluation logs (which variant, what context hash, what timestamp) are typically retained for 30–90 days for debugging. Flag mutation audit events (who changed what rule) are retained longer (12–24 months for SOC2) but must expire too.

# retention-policy.yaml — reviewed and approved by DPO
evaluation_logs:
  ttl_days: 30
  pii_fields: none   # hashed at source
  storage: hot-only  # no cold-tier archive needed

flag_mutation_audit:
  ttl_days: 730      # 24 months — SOC2 requirement
  pii_fields: actor_email   # business data; DPO sign-off in record
  storage: hot → glacier after 90 days

Verification

After applying all steps, run the scan from Step 1 again. Expect zero hits:

grep -rE '"(email|phone|ip_addr|ip_address|device_id|ssn|dob)"' \
  /var/log/flag-evaluation/ \
  && echo "FAIL: raw PII found" || echo "PASS: no raw PII in evaluation logs"

Then simulate a consent withdrawal and confirm the cache is invalidated within your DPIA window:

# Simulate revocation; confirm cached entry is gone within 5 s
curl -X POST https://flag-cache.internal/v1/invalidate \
  -H "Authorization: Bearer ${CACHE_API_TOKEN}" \
  -d '{"targeting_key":"<hmac-hash-of-test-user>"}'
sleep 5
curl -sf "https://flag-cache.internal/v1/entry?key=<hmac-hash>" \
  | jq '.found'   # expect false

Gotchas & Edge Cases

Troubleshooting & FAQ

Why does hashing the email still count as personal data?

A pseudonym is still personal data under GDPR if re-identification is possible with the key — and since you hold the HMAC secret, re-identification is trivially possible. That is fine: pseudonymization is a safeguard that reduces risk and satisfies Article 25 data-by-default requirements. It does not remove the data from GDPR scope entirely. The point is to prevent the data from appearing in logs or exports without protection, not to claim it stops being personal data.

Yes, with a short TTL (5–10 seconds). Cache the result of compliance.consent.tracking-active per user in the application layer, not in the flag provider. On consent withdrawal, invalidate the consent cache entry (same webhook as Step 4) before or simultaneously with the evaluation-context invalidation. Do not cache consent state longer than your DPIA latency budget for revocation propagation.

Anonymous users have no consent record, so compliance.consent.tracking-active is false by default. The consent gate in Step 3 returns the default variant — fully anonymous traffic gets the control experience. If you want to A/B test anonymous users without personal targeting, use a random or session-based targetingKey that is not derived from any personal attribute.