PII Vault Access Patterns

Using the PII vault day to day: the decryption helpers, purpose policies in the dashboard, reading the decryption audit log, and running the console-only migration from Settings.

Last reviewed September 17, 2026Fresh

Overview

This guide covers day-to-day use of the Purpose-Bound PII Vault: calling it from an edge function, keeping purpose policies in the dashboard, reading the decryption audit log, and running the migration. For the module reference (function signatures, the audit table's columns, the migration function's bodies and error codes) see Purpose-Bound PII Vault. For the regulatory mapping see Zero-PII Compliance Guide.

What a decryption actually does:

edge function
  -> decryptPii(field, purpose)
       -> decrypt the AES-256-GCM ciphertext   (or read the legacy plaintext column)
       -> insert a pii_decryption_log row      (fire and forget, never awaited)
  -> plaintext, live only inside this invocation

There is no purpose check and no rate check on that path. The audit row is the control; the policy records in the dashboard are the statement of intent it gets compared against.

TaskWhere it happensWho can do it
Encrypt or decrypt a fieldEdge function code, by importing the modulePlatform engineers; there is no public route and no API key scope
Create or disable purpose policiesDashboard, PII vault tab, Purpose policiesTenant admin on the Enterprise plan
Read the decryption audit logDashboard, PII vault tab, Audit logTenant admin on the Enterprise plan
Encrypt or shred legacy columnsDashboard, Settings, PII Migration ToolingPlatform admin who is also an admin of that tenant

Purpose policies

A purpose policy is a row in shield_pii_policies stating what a tenant expects for one purpose code. The vault does not read the table, so a policy changes nothing at decryption time. It is worth keeping anyway: it is the documented expectation a reviewer measures the audit log against, and it is what an auditor asks to see beside the log.

FieldTypeDefaultMeaning
purpose_codetextnoneThe code this policy governs, unique per tenant
descriptiontextnullFree text shown in the dashboard
max_decryptions_per_hourinteger100The rate the tenant considers normal for this code
ttl_secondsinteger300How long a decrypted value is expected to stay in use
is_activebooleantrueWhether the tenant currently stands behind this policy

Create one.

  1. Open the dashboard and select the PII vault tab (Enterprise plan).
  2. Switch to the Purpose policies view.
  3. Expand Create purpose policy.
  4. Pick a purpose code from the list, or choose Custom and type one. The value is lowercased and spaces become underscores before it is stored.
  5. Set Maximum decryptions per hour (default 100, accepted range 1 to 10,000) and Session length in seconds (default 300, accepted range 10 to 86,400).
  6. Press Create policy.

A second policy for the same code is refused by the table's unique constraint, and the dashboard reports "A policy for this purpose code already exists." Each listed policy has a toggle that flips is_active without deleting it, and a delete button behind a confirmation dialog.

Read the empty state carefully. With no policies, the tab says "Every purpose code is accepted until you create a policy." Every purpose code is also accepted after you create one: the sentence describes the tab's own list, and the vault's behaviour is unchanged either way.

Decryption patterns

Pattern 1: one field, one purpose. The common case, and the shape send-marketing-email uses:

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

const email = 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,
  },
);
if (!email) return; // decryption failed; the caller decides what that means

Use the value inside the invocation and let it fall out of scope. Nothing persists it, and nothing writes it to a log line.

Pattern 2: several fields, one purpose. decryptPiiBatch resolves the fields in parallel and writes one audit row per field:

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

const [email, displayName] = await decryptPiiBatch(
  [
    { encryptedValue: subscriber.email_encrypted, fieldName: "newsletter_subscribers.email" },
    { encryptedValue: profile.display_name_encrypted, fieldName: "profiles.display_name" },
  ],
  {
    purpose: "billing_invoice_generation",
    functionName: "billing-pipeline",
    tenantId: tenant.id,
    userHash: profile.user_hash,
  },
);

profiles.display_name_encrypted is the only encrypted name column in the schema.

Pattern 3: storing a new value. Write the ciphertext and the keyed digest together, so lookups never need the plaintext:

import { encryptAndHash } from "../_shared/pii-vault.ts";
import { IDENTIFIER_HASH_VERSION } from "../_shared/identifier-hash.ts";

const result = await encryptAndHash(address);
if (result) {
  await supabase.from("newsletter_subscribers").update({
    email_encrypted: result.encrypted,
    email_hash: result.hash,           // keyed HMAC-SHA-256, for equality lookups
    hash_version: IDENTIFIER_HASH_VERSION,
  }).eq("id", subscriber.id).eq("tenant_id", tenantId);
}

The digest covers the trimmed, lowercased value, so any spelling of the same address matches. A null result means a key is missing: encryptAndHash fails closed rather than storing an unprotected value, and the row should be left untouched.

Failure handling. encryptPii, decryptPii and decryptPiiBatch never throw, so no call site needs a try or catch around them. They return null (or an array containing null) and log the reason to the function console. Treat null as "this field is unavailable in this invocation" and decide per call site whether to skip the record or fail the request.

Decryption audit log

The audit log is the record of every decryption that returned a value. Its columns and its write path are in Purpose-Bound PII Vault; this section is about reading it.

In the dashboard. PII vault tab, Audit log view:

  • Purpose tiles across the top: up to eight codes, each with its count in the loaded set and a bar showing that count relative to the largest. Selecting a tile filters the table to that code, and selecting it again clears the filter.
  • Full audit log, a collapsed section holding a search box and a purpose filter. Search matches the purpose, function name, field name and user hash.
  • Table columns: Time, Purpose, Function, Field, User (the first 12 characters of the keyed digest) and Source.
  • Refresh, top right, reloads the view.

The view fetches the 200 most recent events for the tenant when it opens. It is a snapshot rather than a live feed, so a decryption made while the tab is open appears after a refresh, and a busy tenant sees only the newest 200 events here.

Reading the Source column.

BadgeMetadata valueWhat it means
Encryptedsource: "encrypted"The ciphertext was read; this field is migrated for that row
Plaintextsource: "plaintext_fallback"A legacy plaintext column was read; that row is not migrated yet

Searching for one data subject. Paste the subject's keyed digest into the search box. The digest is what _shared/identifier-hash.ts produces for that identifier; the dashboard shows only its first 12 characters, and searching on those 12 works. For a complete per-subject record across every table, run a data subject access request instead, which reads the whole log from the database rather than the loaded page. See DSAR Workflows.

Migration and coverage

Two different coverage figures exist, and they answer different questions.

ViewWhereWhat it measures
CoveragePII vault tab, Coverage viewThe share of logged decryptions per field that read ciphertext rather than a plaintext fallback
Encryption CoverageSettings, PII Migration ToolingThe share of rows per table and column that have a non-null encrypted value

The vault tab's Coverage view is derived from the 200 loaded audit rows, so it describes recent access, and its Summary card counts decryptions logged, distinct purpose codes and active policies. Per-field bars read 100 percent, 80 percent or above, or below 80 percent. The Settings panel is the one that answers "is this column migrated", because it counts rows through the status action of shield-pii-migrate.

Run the migration. Settings, PII Migration Tooling, Encrypt Unprotected Rows. Each press encrypts up to 500 rows, so repeat until Encryption Coverage stops moving. The button reads Re-run Encryption once every row is covered. Programmatically, the same call with a tenant-admin session:

await supabase.functions.invoke("shield-pii-migrate", {
  body: { action: "encrypt", tenant_id: tenantId },
});

There is no public endpoint for this. shield-pii-migrate is absent from the Cloudflare worker's PUBLIC_FUNCTIONS list, so a request to https://api.passkeybridge.io/v1/shield-pii-migrate answers 404 with x-pb-reason: unknown-function.

Then shred. Once a table reads 100 percent with plaintext still present, a Shred Plaintext button appears on that table's card. It asks for confirmation, then nulls the plaintext column in batches of 500. The function refuses with 409 while any row still lacks a ciphertext, so the button cannot outrun the encryption pass. After a successful shred the card shows a Shredded badge, and the panel header shows Zero-PII once every tracked column is shredded.

Verify it worked. Press the refresh control in the panel header and read the counts on each card ("38/38 encrypted, 0 plaintext remaining"), then check Migration History: every encrypt and shred run is listed with its action, table and column, rows processed, failure count and date.

The subscriber row is gone from the panel. newsletter_subscribers.email was dropped from the schema in April 2026, and until 2026-09-17 the panel still listed the pair because the function's column map still named it, reporting 100 percent regardless of the table's contents. profiles.display_name is the only tracked column now, and it is where the buttons do work.

A card reading Unknown means the count could not be read, not that the column is uncovered and not that it is complete. The encrypt and shred controls are withheld for that card, because shredding is irreversible and unknown is not a safe basis for starting it.

Reviewing access

The audit log supports review by a person. No shipped code turns it into an alert.

What the dashboard gives you. The purpose tiles show how often each code was used within the loaded 200 events, ordered by count, with a bar for relative volume. There is no time axis and no comparison against a previous period, so the tiles describe what a tenant has been decrypting lately and cannot say whether this hour is unusual. The filters and the hash search let you pull the accesses for one purpose, one function or one subject.

What to look for.

PatternHow it shows upSensible response
A code you did not expectA purpose tile whose name matches no policy and no shipped functionFind the calling function in the Function column; only send-marketing-email should appear today
Volume above the policyTile count far above the policy's max_decryptions_per_hour for the periodNothing was blocked, so check the calling function for a loop or an unintended bulk run
Plaintext fallbacks appearingSource badges reading PlaintextRows are being written without ciphertext, or a column regressed; check Settings, PII Migration Tooling
One subject, many accessesRepeated rows sharing a user hashConfirm against the operation that caused them, and raise it with whoever ran it

What does not exist. pii_decryption_log is read by the PII vault tab, shield-compliance-report and shield-dsar, and by nothing else. No playbook trigger, alert rule or webhook fires from it, and the insider-threat analytics read shield_audit_log rather than this table, so a vault decryption cannot raise their PII access spike anomaly. Build review on the compliance report, which counts decryptions by purpose and function over a period, and on periodic reads of the tab. See Compliance Report and Insider Threat Analytics.

Related from the blog