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.
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
owner,created,expiry, anddata-classificationpopulated per your flag taxonomy
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() }
});
email,ip_address,device_idhashed or removedtargetingKeyderived from a stable HMAC, not the raw identifier
Step 3 — Gate targeting rules on consent state
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.
data-classification: personalmissing consent prerequisite
Step 4 — Invalidate cached context on consent withdrawal
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
- Stable vs. rotating HMAC secrets: rotating the HMAC secret changes every user’s derived
targetingKey, breaking percentage-rollout bucketing for users mid-experiment. Rotate only at natural experiment boundaries, and document the rotation in the audit trail so auditors can reconcile subject IDs across the rotation boundary. - CDN and edge caches: if you serve flag variants from an edge cache, a consent withdrawal may not reach the edge node for several minutes. Either bypass the edge cache for consent-sensitive flags or set
Cache-Control: no-storeon evaluation responses for users with personal-data targeting. - Right-to-erasure (Article 17): if a user exercises their right to erasure, their hashed
targetingKeyin audit logs does not need to be deleted (a hash of deleted data carries no personal information), but document this interpretation in your records of processing activities.
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.
The consent gate adds latency — can I cache the consent check?
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.
How do I handle users who never gave consent (anonymous traffic)?
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.