Shadow Identity Proxy

Ephemeral legacy-format identifiers issued from a keyed-hashed subject, resolvable over TLS or through a BLAST tunnel, and frozen by a hard signal.

Last reviewed September 21, 2026Fresh

Overview

The Shadow Identity Proxy issues a throwaway identifier in a format a legacy system already accepts, so that system never receives the real one. A property management system takes a card number; a GDS takes a six-character record locator. The proxy hands over a value of the right shape, keeps the association encrypted, and lets you revoke it later.

Where it sits. shield-shadow-proxy issues, resolves and revokes. shield_shadow_identities stores one row per proxy with the value encrypted under AES-256-GCM. Dashboard, Data and privacy, Legacy identifiers lists them.

Who can use it. The Enterprise plan, and a dashboard user session. This endpoint accepts no API keys at all: it verifies a Supabase user JWT and resolves your tenant from x-pb-tenant-id, falling back to your oldest membership. issue and revoke additionally require that your membership role is admin, as does list when you filter it by user_identifier.

What is stored about the subject. user_identifier is keyed-hashed with HMAC-SHA-256 under the server-held pepper, and only the digest is written. The raw identifier is never stored or logged. A phone-shaped identifier is hashed as a phone number, which is what lets a revocation triggered by a phone signal match the right rows, and is stamped hash_version 2; anything else is hashed as a subject and stamped 3. Reads resolve every scheme, so rows written under any of them are returned together.

Proxy types and expiry

Three formats, all drawn from crypto.getRandomValues:

proxy_typeShapeTypical use
virtual_cc16 digits, Luhn-valid, 424242 prefixproperty management and other card-taking systems
pnr_code6 characters from an alphabet with no look-alikesGDS record locators
loyalty_idPB followed by 10 digitsloyalty program identifiers

An unrecognised type answers 400 Invalid proxy_type. Use: virtual_cc, pnr_code, loyalty_id. Omitting it gives virtual_cc.

The full value is returned once, at issuance, with a warning that it will not be shown again. After that it lives only as ciphertext: list returns the four-character proxy_prefix and metadata and never the encrypted value, while resolve decrypts and returns the value again to an authenticated tenant member.

Expiry. expires_in_hours sets the lifetime and defaults to 24. Expiry is enforced on read: a resolve or blast_resolve after expires_at sets is_active false on that row and answers 410. No background job sweeps expired rows, so an untouched expired proxy stays flagged active in the table until someone reads it.

Actions and request bodies

Seven actions on one endpoint. Every call is a POST to https://api.passkeybridge.io/v1/shield-shadow-proxy carrying a user session token. A call sent straight to the Supabase functions host answers 403 Direct access denied.

curl -X POST https://api.passkeybridge.io/v1/shield-shadow-proxy \
  -H "Authorization: Bearer <supabase session jwt>" \
  -H "x-pb-tenant-id: <tenant-uuid>" \
  -H "Content-Type: application/json" \
  -d '{"action":"issue","proxy_type":"pnr_code","user_identifier":"+15551234567","expires_in_hours":12}'
ActionBody fieldsWho may call it
issueproxy_type default virtual_cc, user_identifier required, expires_in_hours default 24, metadata optional objecttenant admin
resolveidentity_idany tenant member
revokeidentity_idtenant admin
listuser_identifier optional filterany member, admin when filtering
blast_initclient_public_key as 64 hex characters, ttl_seconds default 300any tenant member
blast_resolvesession_id, identity_idany tenant member
blast_teardownsession_idany tenant member

issue is the only response that carries the value:

{
  "identity": {
    "id": "uuid",
    "proxy_type": "pnr_code",
    "proxy_prefix": "K7QM",
    "issued_at": "2026-09-16T17:00:00Z",
    "expires_at": "2026-09-17T05:00:00Z"
  },
  "proxy_value": "K7QMR4",
  "warning": "Store this value securely. It will not be shown again."
}

resolve returns proxy_type, proxy_value, proxy_prefix and expires_at. revoke returns {"revoked": true}. list returns identities, newest first and capped at 100, each with id, proxy_type, proxy_prefix, user_hash, hash_version, issued_at, expires_at, is_active and revoked_at.

Resolution through a BLAST tunnel. blast_init takes your X25519 public key, generates a server key pair, derives a shared key and returns server_public_key, session_id, key_fingerprint, ttl_seconds and expires_at; the default lifetime is five minutes. blast_resolve then returns what resolve would, encrypted to that tunnel as ciphertext and iv alongside remaining_ttl_ms. blast_teardown marks the session torn down and returns {"destroyed": true} the first time and false after that. The field names differ from the standalone BLAST endpoint, which takes client_public_key_hex and ttl_ms; see BLAST protocol.

StatusBodyCause
400Missing user_identifierissue without a non-empty string
400Missing identity_idresolve or revoke without one
400Missing client_public_key (hex-encoded X25519 public key)blast_init without a key
401Missing authorization header or Invalid auth tokenno session token, or one that did not verify
403Only admins can issue shadow identitiesthe member role is below admin
403Shadow Identity Proxy requires an Enterprise subscriptionthe tenant plan is below Enterprise
404No tenant foundthe user has no membership, or none matching x-pb-tenant-id
404Shadow identity not found or expiredunknown id, wrong tenant, or already revoked
404BLAST session not found. Call blast_init firstunknown session_id
410Shadow identity has expiredpast expires_at; the row is deactivated as a side effect
413Payload too largethe body exceeded 64 KB

Freezing proxies on a hard signal

A shadow proxy is a credential somebody else's system honors, so withdrawal has to be driven centrally. Three shipped paths deactivate rows, and each is blunter than it sounds.

  • Cascade. A hard signal run through shield-cascade deactivates every active shadow identity for the tenant and tears down every open BLAST tunnel for the tenant, in parallel with revoking agent delegates and A2A negotiations. It is tenant-wide: no link exists from a frozen proxy to a particular tunnel, and there is no filter by user. See Cascade classification.
  • Intelligence worker. When an asynchronous risk assessment scores above 0.95, the worker deactivates the active shadow identities whose user_hash equals the event's phone_hash. This is the one path that narrows to a single subject, and it lines up only when the proxy was issued for that phone number, since both digests come from the same keyed hash.
  • Playbook actions. The step_up_auth and quarantine actions both deactivate every active shadow identity for the tenant when a playbook runs them.

Deactivation sets is_active false, and the cascade and intelligence paths also stamp revoked_at. The next resolve answers 404, which is what the downstream system experiences as a dead identifier. Nothing calls out to the property management or reservation system: the proxy simply stops resolving.

Verify it worked

Dashboard, Data and privacy, Legacy identifiers lists the tenant's proxies, newest first, up to 100. The tiles count total issued, active, expired and revoked. Each row shows the type, the four-character prefix, the first 12 characters of the user hash, a status badge, and the issue and expiry dates.

  • The tab reads the table directly under row-level security, and the policy on shield_shadow_identities admits tenant admins only, so a non-admin member sees an empty tab even though resolve works for them through the API.
  • The type column renders virtual_cc as Virtual card and loyalty_id as Loyalty ID. A pnr_code row currently shows the raw value, because the label table is keyed on pnr.
  • A status badge reads Revoked when revoked_at is set, Expired once expires_at has passed, Active while the row is live, and Disabled when is_active is false without a revocation timestamp, which is what a playbook freeze leaves behind.

Every action writes an audit entry: shadow_proxy.issue, shadow_proxy.resolve, shadow_proxy.revoke, and blast.init, blast.resolve and blast.teardown for the tunnel actions. Dashboard, Operations, Audit log is where they land.

Related from the blog