Tenant signing keys and rotation
Per-organization ECDSA signing keys: platform-generated and BYOK provisioning, curve tiers, manual rotation with a 24-hour grace window, and checks.
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.