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

PII redaction boundaries in the evaluation and telemetry paths Raw PII attributes are classified and hashed or stripped before the evaluation call; the telemetry emitter receives only safe attributes and never the raw context. Raw context email · userId · ip Redaction boundary Classify + hash/strip HMAC(userId) · drop email Safe context token · tier · region OpenFeature SDK evaluate flag Telemetry token + variant only
PII is classified and hashed or stripped at the redaction boundary before the safe context is passed to the OpenFeature SDK and telemetry.

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

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.