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.
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_type | Shape | Typical use |
|---|---|---|
virtual_cc | 16 digits, Luhn-valid, 424242 prefix | property management and other card-taking systems |
pnr_code | 6 characters from an alphabet with no look-alikes | GDS record locators |
loyalty_id | PB followed by 10 digits | loyalty 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}'| Action | Body fields | Who may call it |
|---|---|---|
issue | proxy_type default virtual_cc, user_identifier required, expires_in_hours default 24, metadata optional object | tenant admin |
resolve | identity_id | any tenant member |
revoke | identity_id | tenant admin |
list | user_identifier optional filter | any member, admin when filtering |
blast_init | client_public_key as 64 hex characters, ttl_seconds default 300 | any tenant member |
blast_resolve | session_id, identity_id | any tenant member |
blast_teardown | session_id | any 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.
| Status | Body | Cause |
|---|---|---|
| 400 | Missing user_identifier | issue without a non-empty string |
| 400 | Missing identity_id | resolve or revoke without one |
| 400 | Missing client_public_key (hex-encoded X25519 public key) | blast_init without a key |
| 401 | Missing authorization header or Invalid auth token | no session token, or one that did not verify |
| 403 | Only admins can issue shadow identities | the member role is below admin |
| 403 | Shadow Identity Proxy requires an Enterprise subscription | the tenant plan is below Enterprise |
| 404 | No tenant found | the user has no membership, or none matching x-pb-tenant-id |
| 404 | Shadow identity not found or expired | unknown id, wrong tenant, or already revoked |
| 404 | BLAST session not found. Call blast_init first | unknown session_id |
| 410 | Shadow identity has expired | past expires_at; the row is deactivated as a side effect |
| 413 | Payload too large | the 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-cascadedeactivates 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_hashequals the event'sphone_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_authandquarantineactions 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_identitiesadmits tenant admins only, so a non-admin member sees an empty tab even thoughresolveworks for them through the API. - The type column renders
virtual_ccas Virtual card andloyalty_idas Loyalty ID. Apnr_coderow currently shows the raw value, because the label table is keyed onpnr. - A status badge reads Revoked when
revoked_atis set, Expired onceexpires_athas passed, Active while the row is live, and Disabled whenis_activeis 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.