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.
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.
| Surface | What it is | Who reaches it |
|---|---|---|
_shared/pii-vault.ts | Shared module with four exported functions | Edge functions, by import |
pii_decryption_log | Append-only audit table | Tenant admins read it, only the service role writes it |
shield_pii_policies | Purpose policy records | Tenant admins, through the dashboard |
shield-pii-migrate | Console-only edge function | A platform admin who is also an admin of the named tenant |
| PII vault tab | Audit log, purpose policies and coverage views | Dashboard, 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.
| Guarantee | What the code does |
|---|---|
| Never throws | encryptPii, decryptPii and decryptPiiBatch catch every error and return null, so a decryption failure cannot crash the calling function |
| Fails closed | encrypt() 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 |
| Audited | Every decryption that returns a value inserts a pii_decryption_log row naming the purpose code, the calling function and the field |
| Encrypted at rest | AES-256-GCM under the platform master key, stored as base64 of a 12-byte IV followed by the ciphertext and its GCM tag |
| Dual read | The 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.
| Function | Argument | Returns |
|---|---|---|
encryptPii | plaintext: string | The AES-256-GCM ciphertext, or null when the master key is unset |
decryptPii | field: PiiField, purpose: DecryptionPurpose | The plaintext, or null when nothing could be resolved |
decryptPiiBatch | fields: PiiField[], purpose: DecryptionPurpose | One entry per field, each a string or null, resolved in parallel |
encryptAndHash | plaintext: 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`.
encryptedValueis present and decrypts: return it, audit rowsource: "encrypted".encryptedValueis present, decryption fails, andplaintextFallbackis set: return the fallback, audit rowsource: "plaintext_fallback".- Only
plaintextFallbackis set: return it, audit rowsource: "plaintext_fallback". - Neither resolves: return
nulland 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 charactersThe 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 code | Reserved for | Emitted by shipped code |
|---|---|---|
marketing_email_send | send-marketing-email broadcasts | Yes |
support_ticket_reply | Resolving an identity to answer a support ticket | No |
billing_invoice_generation | Rendering a name or address onto an invoice | No |
account_recovery_verification | Identity resolution during social recovery | No |
compliance_data_subject_request | Data subject request fulfilment | No |
pii_migration | Bulk encryption of legacy columns | No |
admin_user_lookup | Resolving a display name for the dashboard | No |
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.
| Column | Type | What it holds |
|---|---|---|
id | uuid | Primary key |
tenant_id | uuid | Tenant scope, null for system-level work, cascades when the tenant is deleted |
user_hash | text | Keyed HMAC-SHA-256 digest of the data subject, or null when the caller passed none |
field_name | text | The field decrypted, as the caller named it, for example newsletter_subscribers.email |
purpose | text | The declared purpose code |
function_name | text | The edge function that performed the decryption |
decrypted_at | timestamptz | When it happened; this table has no created_at column |
metadata | jsonb | Always 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.
| Status | Body | Cause |
|---|---|---|
| 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 |
| 400 | Unknown table: audit. Valid: profiles | shred named a table outside the column map. newsletter_subscribers answers this too, since 2026-09-17 |
| 503 | Cannot shred: the coverage check for profiles could not be read... | The pre-shred check failed, so it is unknown whether every row is encrypted |
| 400 | Unknown action. Valid: status, encrypt, shred | Unrecognised action |
| 409 | Cannot shred: 12 rows in profiles still lack encrypted values. Run encrypt first. | Coverage is below 100 percent, so nothing is nulled |
| 500 | Shred partially failed after 200 rows | A 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".