Masking PII in the Evaluation Context
This how-to is part of Context Enrichment Strategies for Targeting. Targeting rules need user attributes to function — tier, region, beta opt-in. But those attributes travel alongside raw identifiers that qualify as personal data under GDPR and similar regulations. If a raw email address or user ID reaches a vendor SDK’s telemetry pipeline, a flag evaluation log, or a browser payload, you have a data leak that a flag evaluation never needed to create.
The goal is not to strip all identifying information — targeting requires stable identifiers. The goal is to ensure that no attribute capable of directly identifying a person travels past your service boundary in a form that could be linked back to them without additional computation.
Prerequisites
Step 1 — Classify every context attribute as identifier, sensitive, or safe
Before writing any redaction code, list every attribute your service puts into the evaluation context and assign it a class. This classification drives every decision downstream.
| Class | Description | Examples | Treatment |
|---|---|---|---|
| Identifier | Stably identifies a person | userId, email, externalId |
Hash or tokenize |
| Sensitive | Not directly identifying but regulatorily protected | ipAddress, deviceId, phone |
Hash or drop |
| Safe | No personal data, no stable link to a person | tenantTier, region, environment, betaOptIn |
Pass through |
Write this table into your flag taxonomy metadata so every engineer knows which attributes require handling and does not have to infer it.
Step 2 — Hash or tokenize the targeting key
The targetingKey is the attribute most likely to be a raw user ID or email. It must be stable enough for percentage-based rollouts with sticky bucketing to work correctly, but it must not be reversible to a natural person without a secret key. HMAC-SHA-256 with a per-environment secret achieves both.
import { createHmac } from 'crypto';
const TARGETING_KEY_SECRET = process.env.FLAG_HMAC_SECRET; // rotate per environment
function tokenizeTargetingKey(rawUserId: string): string {
return createHmac('sha256', TARGETING_KEY_SECRET!)
.update(rawUserId)
.digest('hex')
.slice(0, 16); // 16 hex chars = 64 bits; enough entropy for bucketing
}
// Usage:
const context = {
targetingKey: tokenizeTargetingKey(req.userId),
tenantTier: 'enterprise',
region: 'us-east-1',
};
A plain SHA-256 without a secret is a one-way hash but is still vulnerable to rainbow-table attacks for common user IDs (sequential integers, well-known email patterns). HMAC with a rotated secret eliminates that attack surface.
Pitfall: truncating the HMAC to fewer than 32 bits risks bucketing collisions. 64 bits (16 hex chars) is safe for populations below ~10 million users; use the full 256 bits if your scale demands it.
Step 3 — Redact sensitive attributes before the evaluation call
Strip or hash any sensitive attribute that snuck into the context during enrichment. Run this as a dedicated redaction step immediately before calling the SDK — after enrichment, before evaluation.
const ATTRIBUTE_REDACTION: Record<string, 'drop' | 'hash'> = {
email: 'drop',
phone: 'drop',
ipAddress: 'hash',
deviceId: 'hash',
};
function redactContext(
ctx: Record<string, unknown>,
secret: string,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(ctx)) {
const policy = ATTRIBUTE_REDACTION[key];
if (policy === 'drop') continue;
if (policy === 'hash' && typeof value === 'string') {
result[key] = createHmac('sha256', secret).update(value).digest('hex').slice(0, 16);
} else {
result[key] = value;
}
}
return result;
}
const safeCtx = redactContext(enrichedCtx, TARGETING_KEY_SECRET!);
const variant = await client.getBooleanValue('billing.invoicing.pdf-v2', false, safeCtx);
Define the redaction policy table as a constant, not inline logic, so it can be reviewed in a single place during a compliance audit.
Step 4 — Verify no PII appears in logs, traces, or vendor telemetry
Redacting at the API layer is necessary but not sufficient. Verify that the SDK’s built-in telemetry, your OpenTelemetry exporter, and any vendor agent do not capture the full context object.
# Search application logs for raw PII patterns after a flag evaluation
grep -E '"email"\s*:\s*"[^@]+@[^"]+"' /var/log/app/evaluation.log | head -5
grep -E '"userId"\s*:\s*"u_[0-9]+"' /var/log/app/evaluation.log | head -5
# Expect zero output. Any match is a redaction gap.
For OpenTelemetry, audit your span attribute exports:
// WRONG — emits the full context, including any unhashed sensitive attributes
span.setAttributes({ 'flag.context': JSON.stringify(context) });
// RIGHT — emit only the safe, non-identifying fields
span.setAttributes({
'flag.key': 'billing.invoicing.pdf-v2',
'flag.variant': variant,
'flag.targeting_key': safeCtx.targetingKey as string, // already tokenized
'flag.correlation': safeCtx.correlationId as string,
});
Run this grep assertion in CI against a test log produced by a staging evaluation run. A failed assertion (any match) blocks the build.
Verification
Run the full pipeline against a controlled input and confirm the output contains no raw identifiers:
// Jest / Vitest
import { redactContext } from './context-redaction';
test('redactContext strips email and hashes ipAddress', () => {
const raw = {
targetingKey: 'tok-abc123', // already tokenized upstream
email: 'user@example.com', // must be dropped
ipAddress: '203.0.113.42', // must be hashed, not raw
tenantTier: 'enterprise', // safe, must pass through
};
const result = redactContext(raw, 'test-secret');
expect(result.email).toBeUndefined();
expect(result.ipAddress).toMatch(/^[0-9a-f]{16}$/);
expect(result.tenantTier).toBe('enterprise');
});
Also verify the HMAC output for the same input and secret is deterministic — the same userId must produce the same token on every call so bucketing is stable.
Gotchas & Edge Cases
- Vendor SDK auto-instrumentation: some vendor providers collect the evaluation context automatically for their own analytics. Confirm in the vendor’s privacy documentation whether the context is transmitted to their cloud and whether field-level suppression is available. If it is not, strip sensitive fields before the OpenFeature call, not inside a span hook.
- Audit trail completeness vs. PII minimization: your audit log needs to record who changed a flag and which targeting rule was active, but it does not need the raw context of every evaluation. Log the token, the variant, and the rule that matched — not the raw
userId. - Secret rotation: rotating the HMAC secret changes every token, which shifts bucket assignments for percentage-based flags and disrupts ongoing experiments. Coordinate secret rotation with experiment freeze windows or keep two active secrets during the transition period.
Troubleshooting & FAQ
How do I find out which attributes the vendor SDK is collecting?
Enable network-level request logging (Charles Proxy, mitmproxy, or your service mesh’s egress capture) and inspect the payload the SDK sends to the vendor endpoint. Alternatively, check the vendor’s SDK source or their data-processing agreement. If you cannot verify it, pass only the attributes the rule actually needs — omit everything else from the context before the call.
Our targeting rules use email domain for segmentation. Can we keep the domain without keeping the address?
Yes. Split the email at @ and pass only the domain as a separate context attribute (emailDomain: "example.com"). Drop the full address. Domain-based rules still work; the PII-bearing local part never enters the context.
Does hashing the targetingKey break experiment assignment reproducibility?
No, provided the hash function and secret are stable for the life of the experiment. HMAC-SHA-256 is deterministic: the same raw ID plus the same secret always yields the same token. Bucketing algorithms operate on the token, not the raw ID, so assignments remain stable. Document the secret’s validity window and coordinate rotation with your experiment team.