Purpose-Bound PII Vault

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

Last reviewed September 17, 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 and is never called from a browser or over the public API. Low-entropy identifiers (phone numbers, email addresses, subject identifiers) are never stored in the clear anywhere on the platform: they are keyed HMAC-SHA-256 digests produced by _shared/identifier-hash.ts under a server-held pepper. The vault covers the smaller set of operational fields that have to be read back in full, today a newsletter email address and a profile display name. It encrypts them with AES-256-GCM, decrypts them inside a single function invocation for a declared purpose, and writes one audit row per decryption.

SurfaceWhat it isWho reaches it
_shared/pii-vault.tsShared module with four exported functionsEdge functions, by import
pii_decryption_logAppend-only audit tableTenant admins read it, only the service role writes it
shield_pii_policiesPurpose policy recordsTenant admins, through the dashboard
shield-pii-migrateConsole-only edge functionA platform admin who is also an admin of the named tenant
PII vault tabAudit log, purpose policies and coverage viewsDashboard, Enterprise plan

One shipped function decrypts through the vault today: send-marketing-email resolves subscriber addresses for a broadcast under the purpose code marketing_email_send. Every other subsystem works on keyed digests and never needs the plaintext.

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 patent application covers purpose-bound decryption with auditable purpose codes (Serial No. 64/014,323).

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. 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 backfills encrypted columns for rows written before the vault, then nulls the plaintext original once every row is covered. It is console-only: the name is absent from the Cloudflare worker's PUBLIC_FUNCTIONS list, so https://api.passkeybridge.io/v1/shield-pii-migrate answers 404 with x-pb-reason: unknown-function. Invoke it from a signed-in dashboard session.

Authorization. A Supabase user JWT, the platform admin role (has_role), and admin membership of the tenant named in the body (is_tenant_admin). All three are checked on every action.

Actions.

// Coverage per column, plus the 20 most recent runs
await supabase.functions.invoke("shield-pii-migrate", {
  body: { action: "status", tenant_id: tenantId },
});

// Encrypt up to 500 unprotected rows per call; safe to repeat
await supabase.functions.invoke("shield-pii-migrate", {
  body: { action: "encrypt", tenant_id: tenantId },
});

// Null the plaintext column of one table, once its coverage reads 100 percent
await supabase.functions.invoke("shield-pii-migrate", {
  body: { action: "shred", tenant_id: tenantId, table: "profiles" },
});

action defaults to encrypt when omitted, tenant_id is required on every action, and table is required by shred and must be profiles, the only entry left in the column map. Encryption is idempotent: it selects only rows whose encrypted column is still null.

Responses. status, then encrypt, then shred:

{
  "coverage": {
    "profiles.display_name": { "total": 38, "encrypted": 26, "plaintext_only": 12, "pct": 68 }
  },
  "runs": []
}
{ "success": true, "subscribers_encrypted": 0, "profiles_encrypted": 12, "errors": [] }
{ "success": true, "table": "profiles", "column": "display_name", "rows_shredded": 38 }

Errors.

StatusBodyCause
401{"error":"Unauthorized"}No Authorization header, or the JWT did not resolve to a user
403{"error":"Forbidden"}The caller lacks the platform admin role or is not an admin of that tenant
400{"error":"Invalid JSON body"}The body did not parse
400{"error":"tenant_id required"}tenant_id was missing
400Unknown table: audit. Valid: profilesshred named a table outside the column map. newsletter_subscribers answers this too, since 2026-09-17
503Cannot shred: the coverage check for profiles could not be read...The pre-shred check failed, so it is unknown whether every row is encrypted
400Unknown action. Valid: status, encrypt, shredUnrecognised action
409Cannot shred: 12 rows in profiles still lack encrypted values. Run encrypt first.Coverage is below 100 percent, so nothing is nulled
500Shred partially failed after 200 rowsA batch update failed part way; the partial count is recorded as a run

In the dashboard. Settings, PII Migration Tooling. Encryption Coverage lists each table and column with its counts and a progress bar. Encrypt Unprotected Rows runs a batch (the button reads Re-run Encryption once coverage is complete). At 100 percent with plaintext still present, a Shred Plaintext button appears per table behind a confirmation prompt; after shredding, the row shows a Shredded badge and the panel header shows Zero-PII. Migration History lists every run with its action, table, row counts and date, read from shield_pii_migration_runs.

There is one column left, and that is the whole map. newsletter_subscribers.email was dropped from the schema in April 2026, so subscriber addresses exist only as ciphertext plus keyed digest and there is nothing to encrypt, count or shred. Until 2026-09-17 the column map still named it: every query mentioning the dropped column failed, the errors were discarded, the counts defaulted to zero and the pair reported 100 percent coverage. The entry is gone, so the status view lists profiles.display_name alone and shred on the subscriber table answers the unknown-table 400.

A count that cannot be read now says so. Each coverage line reports null counts with "unavailable": true when its query fails, and the dashboard renders that as Unknown rather than as complete. The shred path refuses with 503 rather than proceeding, because its "is every row encrypted" guard failing used to read as "yes".

Related from the blog