Tenant signing keys and rotation
Per-organization ECDSA signing keys: platform-generated and BYOK provisioning through shield-admin-mutations, the curve tiers, manual rotation with a 24-hour grace window, and how to confirm one ran.
Key architecture
Each organization signs its credentials, DID document entries and other artifacts with its own key pair. The private key is encrypted with AES-256-GCM before it is written to shield_tenant_keys; raw private material is never stored in the clear and never leaves the platform.
| Mode | Key id prefix | Source |
|---|---|---|
| Platform-generated | gen- | generated server side with Web Crypto |
| BYOK | byok- | imported from key material you supply |
Algorithm. ECDSA on a NIST curve: P-256 with ES256 by default, P-384 with ES384 for organizations on the ML-DSA-87 post-quantum tier. Every operation reads the curve from the key's own JWK, so both can coexist and each verifies correctly. The curve for a new key follows your tier and is never chosen by the caller; an existing key keeps its curve until an explicit rotation.
Public key. Exported as a JWK (kty: "EC", the curve, the matching alg, kid set to the key id, key_ops: ["verify"]) and published in the organization's DID document, so an outside verifier needs nothing secret from you.
Who can use it. Provisioning and rotation are console-only, through shield-admin-mutations, which requires a dashboard session whose user is a tenant admin. The function is not in the worker allowlist, so there is no https://api.passkeybridge.io/v1/shield-admin-mutations. There is also no key management panel in the dashboard today: an organization that needs a key provisioned or rotated asks support to run the action. A tenant that has no key yet gets a platform key provisioned automatically the first time something needs to sign, so signing never falls back to a shared secret.
Importing your own key
Import your own key with the provision_tenant_key action and a base64 key_b64 field. Two formats are accepted, detected by looking at the decoded bytes: a leading { is read as JWK, anything else as PKCS#8 DER.
| Format | Encoding | Requirements |
|---|---|---|
| JWK | base64 of the JSON text | kty: "EC", crv of P-256 or P-384, and the private component d |
| PKCS#8 DER | base64 of the DER bytes | an EC private key on P-256 or P-384 |
{
"action": "provision_tenant_key",
"tenant_id": "<uuid>",
"key_b64": "<base64 of the JWK JSON or PKCS#8 DER>",
"label": "Production signing key"
}The import derives the public key from what you sent, mints a key id of byok- plus eight characters, encrypts the private key, and stores the pair as the active key.
{
"ok": true,
"key_id": "byok-3f9c21ab",
"algorithm": "ES256",
"public_key_jwk": { "kty": "EC", "crv": "P-256", "alg": "ES256", "kid": "byok-3f9c21ab", "x": "...", "y": "...", "key_ops": ["verify"] },
"byok": true
}| Status | Body | Cause |
|---|---|---|
| 400 | {"error":"Invalid base64 key material"} | key_b64 did not decode |
| 400 | error naming the curve requirement | a JWK that is not EC P-256 or P-384, or a DER blob on another curve |
| 400 | error naming the missing component | a JWK without d |
| 401 | {"error":"Unauthorized"} | no session, or a session that did not resolve |
| 403 | {"error":"Forbidden: admin role required"} | the user is not an admin of this organization |
| 409 | {"error":"Tenant already has an active key","key_id":"..."} | provisioning when a key is already active; rotate instead |
An imported key keeps the curve it arrived on. The tier-derived curve applies only to keys the platform generates.
Platform-generated keys
Omit key_b64 and the platform generates the pair itself.
{ "action": "provision_tenant_key", "tenant_id": "<uuid>" }The key is generated with Web Crypto on the curve bundled with the organization's post-quantum tier, the public key is exported as a JWK with a gen- key id, the private key is encrypted, and the row is inserted with is_active: true. The response shape matches the BYOK one with "byok": false.
Two guards are worth knowing. A partial unique index allows at most one active key per organization, so two concurrent provisions cannot both win; the loser reads back the winner's key. And the curve lookup fails closed: if the tier cannot be read, provisioning errors rather than quietly issuing a P-256 key to an organization that should be on P-384.
Automatic provisioning uses this same path. The first operation that needs a signature for an organization without an active key provisions a platform key inline, so getActiveTenantKey is never empty for a live organization.
Signing and verification
Signing. The stored private key is decrypted, the curve is read from the key itself, and the payload is signed with the matching hash (SHA-256 for P-256, SHA-384 for P-384). The result carries the base64url signature, the key_id and the algorithm, so a verifier knows which key and which algorithm to use.
Verification. Outside verifiers use the public JWK from the DID document: import it for verify usage, decode the base64url signature, verify. Because the scheme is asymmetric, nothing secret has to be shared to let a third party check a signature.
An internal helper that derived the public key from the encrypted private key is still exported but deprecated; new code verifies from the public JWK.
Signature encoding. base64url (RFC 4648 section 5): URL-safe alphabet, no padding.
Relationship to post-quantum signing. This key is the classical half. Where an artifact is signed hybrid, the post-quantum half is ML-DSA and is held separately; see Security and cryptography. Classical signatures here are ES256 or ES384.
Rotation
Rotation is manual. rotate_tenant_key deactivates the current key and provisions a replacement in one call.
{
"action": "rotate_tenant_key",
"tenant_id": "<uuid>",
"key_b64": "<optional: base64 of a new BYOK key>",
"label": "Rotated 2026-09-16"
}{ "ok": true, "key_id": "gen-77b0c412", "old_key_id": "gen-1a2b3c4d", "byok": false }What changes: the old row gets is_active: false, rotated_at set to now, and grace_expires_at set 24 hours out. The new key becomes active. During those 24 hours verification accepts both keys, so a credential signed just before the rotation still verifies; after it, only the active key is accepted. Nothing is deleted, and the old public key stays readable for anything that needs to check a historical signature.
Omit key_b64 and the replacement is a platform key on your tier's curve, so a rotation can never move an ES384 organization back to ES256.
Automatic rotation does not run. The repository contains a rotation-policy module with a shield_rotation_policies table, interval and grace-period settings and a processor for due policies, and the interval is clamped to between 1 hour and 8,760 hours. Nothing invokes that processor: there is no cron job and no edge function that calls it, and next_rotation_at is never acted on. Treat the policy rows as configuration with no scheduler behind them, and rotate on your own calendar by asking for rotate_tenant_key to be run.
Confirming a provision or rotation
There is no key management panel in the dashboard, so confirmation is through the audit log and the public DID document.
Audit log. Dashboard > Audit log shows tenant_key.provision ("Tenant Key Provisioned") or tenant_key.rotate ("Tenant Key Rotated"), with resource_id set to the new key id. Expanding the row shows the changes: for a provision, the algorithm, whether it was BYOK, the curve actually stored and the curve your tier asked for; for a rotation, the old key id.
DID document. Resolve your organization's DID through shield-did-resolve and check that the verification method carries the new kid and the expected alg.
A signature. Issue or verify a credential and check the key_id in the result; it should be the new key. Within the grace window a credential signed with the previous key still verifies, which is the point of the window.
What is not available today: a rotation countdown, an auto-rotation toggle, an interval editor and a rotation history view. Those belong to the unscheduled policy module described above, and no dashboard surface renders them.
Related from the blog
- The Pre-Filter Pattern: Paying for Heavy Fraud Signals Only on the Suspicious Tailsecurity · 9 min read
- Running CAEP in Production: What Signing Outbound SETs Taught Us About Receiverssecurity · 13 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read