Skip to main content

Purpose-Bound PII Vault

The shared PII vault: AES-256-GCM at rest, just-in-time decryption for a declared purpose, the decryption audit log, and the console-only migration tool.

Last reviewed October 26, 2026Fresh

Architecture

The Purpose-Bound PII Vault is a shared module, supabase/functions/_shared/pii-vault.ts, that other edge functions import. It has no route of its own. Signal identifiers use keyed HMAC-SHA-256 digests under a server-held pepper. The vault provides AES-256-GCM encryption and a purpose-coded decryption audit for operational values such as newsletter delivery addresses. Account authentication details and administrative content are separate data categories; see Data handling. A retained encrypted profile-name counterpart belongs to legacy migration state, while new SCIM labels use their own account-and-organization-bound encrypted envelope.

SurfaceWhat it isWho reaches it
_shared/pii-vault.tsShared module with four exported functionsEdge functions, by import
pii_decryption_logAppend-only audit tableTenant admins of an Enterprise organization read it, only the service role writes it
shield_pii_policiesPurpose policy recordsTenant admins of an Enterprise organization, through the dashboard; its members can read them
shield-pii-migrateConsole-only edge functionPlatform-and-tenant admin for legacy coverage/encryption; configured service key only for account-name backfill
PII vault tabAudit log, purpose policies and coverage viewsDashboard, Enterprise plan

send-marketing-email resolves subscriber addresses through the vault under marketing_email_send. Other encrypted values, including SCIM display labels and provider credentials, use their own checked read paths; the vault audit is not a record of every decryption on the platform.

GuaranteeWhat the code does
Never throwsencryptPii, decryptPii and decryptPiiBatch catch every error and return null, so a decryption failure cannot crash the calling function
Fails closedencrypt() throws when SHIELD_ENCRYPTION_KEY is unset and hashIdentifier() throws when IDENTIFIER_HASH_PEPPER is unset, so encryptAndHash returns null rather than storing an unprotected value
AuditedEvery decryption that returns a value inserts a pii_decryption_log row naming the purpose code, the calling function and the field
Encrypted at restAES-256-GCM under the platform master key, stored as base64 of a 12-byte IV followed by the ciphertext and its GCM tag
Dual readThe ciphertext is preferred, a legacy plaintext column is read only while that column is still being migrated, and the audit row records which was used

A filed application covers purpose-bound decryption with auditable purpose codes.

For how this data model maps to GDPR, CCPA and CPRA, SEC Regulation S-ID and the SOC 2 readiness evidence, see the Zero-PII Compliance Guide.

Encryption and decryption

Four functions are exported, and none of them throws.

FunctionArgumentReturns
encryptPiiplaintext: stringThe AES-256-GCM ciphertext, or null when the master key is unset
decryptPiifield: PiiField, purpose: DecryptionPurposeThe plaintext, or null when nothing could be resolved
decryptPiiBatchfields: PiiField[], purpose: DecryptionPurposeOne entry per field, each a string or null, resolved in parallel
encryptAndHashplaintext: string{ encrypted, hash }, or null when either key is unset

The two argument shapes:

interface PiiField {
  encryptedValue: string | null;      // the ciphertext column
  plaintextFallback?: string | null;  // the legacy column, during migration only
  fieldName: string;                  // recorded on the audit row
}

interface DecryptionPurpose {
  purpose: string;       // free-form purpose code, recorded verbatim
  functionName: string;  // the calling edge function
  tenantId?: string;     // audit row scope; omit for system-level work
  userHash?: string;     // keyed digest of the data subject, never a raw value
  metadata?: Record<string, unknown>; // merged into the audit row's metadata
}

The one shipped call site, in send-marketing-email:

import { decryptPii } from "../_shared/pii-vault.ts";

const recipient = await decryptPii(
  {
    encryptedValue: subscriber.email_encrypted,
    plaintextFallback: null,
    fieldName: "newsletter_subscribers.email",
  },
  {
    purpose: "marketing_email_send",
    functionName: "send-marketing-email",
    tenantId: tenant.id,
    userHash: subscriber.email_hash,
    metadata: { subscriber_id: subscriber.id },
  },
);

Resolution order in `decryptPii`.

  1. encryptedValue is present and decrypts: return it, audit row source: "encrypted".
  2. encryptedValue is present, decryption fails, and plaintextFallback is set: return the fallback, audit row source: "plaintext_fallback".
  3. Only plaintextFallback is set: return it, audit row source: "plaintext_fallback".
  4. Neither resolves: return null and write no audit row.

Storing a new value. encryptAndHash produces both the ciphertext for the _encrypted column and the lookup digest for the _hash column:

const result = await encryptAndHash("user@example.com");
// result.encrypted -> AES-256-GCM ciphertext
// result.hash      -> keyed HMAC-SHA-256 digest, 64 lowercase hex characters

The digest is hashIdentifier("email", value): HMAC-SHA-256 over the trimmed, lowercased address under the server-held pepper. It is never a plain SHA-256. An unkeyed digest of an email address can be reversed by enumerating candidate addresses, so keying is the control that makes a leaked digest column disclose equality and nothing else. Write hash_version = 2 beside the digest, as shield-pii-migrate does, so rows written before keyed hashing (version 1, unkeyed) stay identifiable.

Purpose codes

Every decryption declares a purpose code. The vault accepts any string, records it verbatim on the audit row, and enforces nothing: decryptPii runs no allowlist check, no counter and no expiry check.

Purpose codeReserved forEmitted by shipped code
marketing_email_sendsend-marketing-email broadcastsYes
support_ticket_replyResolving an identity to answer a support ticketNo
billing_invoice_generationRendering a name or address onto an invoiceNo
account_recovery_verificationIdentity resolution during social recoveryNo
compliance_data_subject_requestData subject request fulfilmentNo
pii_migrationBulk encryption of legacy columnsNo
admin_user_lookupResolving a display name for the dashboardNo

Those seven are the canonical conventions carried in the module header and offered in the dashboard's policy form. Only marketing_email_send reaches the log from shipped code, so an audit log holding nothing else is the expected state rather than a sign of missing instrumentation. shield-pii-migrate imports encryptAndHash alone and never decrypts, so it writes no pii_migration rows, and shield-dsar reads the log table instead of decrypting through the vault.

Purpose policies record intent. A tenant admin can store one policy per purpose code in shield_pii_policies, with max_decryptions_per_hour (default 100) and ttl_seconds (default 300). No edge function reads that table. A policy therefore neither blocks nor throttles a decryption; it states what the tenant expects, so a reviewer can compare it against the audit log. Treat the hourly limit and the session length as review criteria, and see PII Vault Access Patterns for the dashboard steps.

Decryption audit log

Every decryption that returns a value inserts one row into pii_decryption_log. The insert is fire and forget with the service role: the calling function does not await it, and an insert failure is written to the function console without failing the caller.

ColumnTypeWhat it holds
iduuidPrimary key
tenant_iduuidTenant scope, null for system-level work, cascades when the tenant is deleted
user_hashtextKeyed HMAC-SHA-256 digest of the data subject, or null when the caller passed none
field_nametextThe field decrypted, as the caller named it, for example newsletter_subscribers.email
purposetextThe declared purpose code
function_nametextThe edge function that performed the decryption
decrypted_attimestamptzWhen it happened; this table has no created_at column
metadatajsonbAlways carries source, plus whatever the caller added

source is encrypted when the ciphertext was read and plaintext_fallback when a legacy column was used.

Row-level security allows a tenant admin to SELECT the tenant's rows while the organization is on Enterprise: plan enterprise or enterprise_dedicated, with billing neither unpaid nor cancelled, the rule the plan-gated functions apply. The purpose policies carry the same condition. There is no client insert, update or delete path at all: only the service role writes, which is what makes the table usable as evidence.

Three shipped readers: the PII vault tab (most recent 200 rows for the tenant), shield-compliance-report (purpose, function name and timestamp over the reporting period), and shield-dsar, which exports the rows matching a user_hash on an access request and, on an erasure request, nulls user_hash and tombstones metadata while keeping the row so the access record survives the erasure.

Verify it worked. Dashboard, PII vault tab, Audit log view. A new decryption appears as a row with its Purpose, Function, Field, the first 12 characters of the user hash, and a Source badge reading Encrypted or Plaintext. The view loads the most recent 200 events when it opens and does not stream, so press Refresh after triggering a decryption.

Migration and shredding

shield-pii-migrate has two authorization lanes. The existing status and encrypt actions require a signed-in platform admin who is also an admin of the named tenant. They inspect or preserve the legacy encrypted profile counterpart. They do not preserve the account's authentication name or its organization-specific provisioning labels, and encryption coverage is not proof that account-name cleanup is complete.

Account-name cleanup uses `backfill-profile-names`. This is a manual, global operator action, never a dashboard button or a scheduled job. It accepts only the actual configured service key as its Bearer credential; an administrator session or a JWT claiming a service role is insufficient. The selected project's direct function endpoint is used, not api.passkeybridge.io. For staging, select the dashboard labeled PasskeyBridge-Staging; production must not be touched.

Preview is the default:

{"action":"backfill-profile-names","dry_run":true,"batch_size":50}

It returns dry_run, the exact global remaining count, selected and batch_size. Profiles are account-scoped rather than tenant-scoped. No name or authentication metadata is returned.

After reviewing that count, a write request explicitly supplies it:

{"action":"backfill-profile-names","dry_run":false,"expected_count":12,"batch_size":50,"confirm":"BACKFILL_AUTH_AND_CLEAR_PROFILE_NAMES"}

Replace 12 with the count just observed. Batches are 1 to 100 rows. For each row, one transaction locks the profile and auth account, checks the source timestamp, preserves unrelated authentication metadata, sets full_name only when it does not conflict, preserves encrypted labels for existing memberships without replacing newer IdP labels, then clears and reads back the profile name. Existing encrypted profile counterparts are retained.

A conflicting auth name, stale source or missing account stops the batch; earlier successful rows remain committed. The response carries counts, blocked_profile_id and the last successful next_cursor. Review the conflict securely rather than automatically overwriting it. Preview again before each write and use the current count. An optional after_id UUID advances past a reviewed row; skipped rows still count in remaining, and restarting without a cursor revisits them. Completion means an exact post-batch remaining of zero, not the length of a returned page.

shred on profiles now returns 409, so the older action cannot bypass these preservation steps. Settings, PII Migration Tooling shows Account backfill required instead of a profile shred control. A legacy card's encrypted count does not certify the auth backfill. No hosted cleanup is implied by this documentation.

The repository operator runbook is docs/runbooks/profile-name-backfill.md. Release the additive schema before the dependent handlers and confirm its ledger/readback; the apply and deploy jobs start independently. The SQL rollback restores only the previous signup hook and retains accepted ciphertext and auth metadata. Keep cipher-aware SCIM readers and auth-record rendering after names have migrated; reverting to older readers first would lose label readback.

Related from the blog