Skip to content

Signing keys and rotation

Signing is EdDSA over Ed25519 everywhere — ID tokens, access tokens, verifiable credentials, and Admin Portal links. There is no RSA on the signing path and no algorithm negotiation to get wrong.

The platform holds one issuer key of its own, used for apex-issued tokens and portal links. Beyond that, every organization gets its own Ed25519 key, generated the first time it is needed.

A tenant’s private JWK is never stored in the clear: it is AES-GCM envelope-encrypted under the platform master key before it is written, and decrypted into memory only when a signature is needed. Imported keys are memoized per isolate, so the hot path does not repeat the unwrap.

This is what makes tenant isolation cryptographic rather than a matter of query hygiene. A token signed by Acme’s key does not verify against Globex’s JWKS, whatever else goes wrong.

URLContains
https://acme.oauth.work/.well-known/jwks.jsonThe tenant’s public keys
https://acme.oauth.work/.well-known/did.jsonThe tenant’s did:web document
https://oauth.work/.well-known/jwks.jsonThe platform’s public keys

Published material is cacheable — it is keyed by kid, so a new key is a new cache entry rather than an invalidation. Responses carry Cache-Control: public, max-age=300, stale-while-revalidate=3600, and the platform’s key cache TTL is 24 hours.

Always resolve the key set from the token’s own iss. Hardcoding a JWKS URL works right up until a token arrives from a tenant host instead of the apex.

POST /v1/organizations/org_01J…/keys/rotate
Authorization: Bearer sk_live_…
{ "rotated": true, "active_kid": "k_2f9c…" }

Requires org:write, and writes a key_rotated audit event.

Rotation retires the current key and generates a new one. The retired key stays published. The JWKS returns every key the org has ever had, newest first, and each token names the key that signed it in its kid header — so tokens signed before the rotation continue to verify for their remaining lifetime, and verifiers that select by kid need no coordination at all.

What this means in practice:

  • New signatures use the new key immediately.
  • Access tokens and ID tokens signed with the old key keep verifying until they expire (one hour).
  • Credentials signed with the old key keep verifying for as long as they live — up to 90 days by default, which is exactly why retired keys are not withdrawn.
  • A verifier caching JWKS may take up to five minutes to see the new key. If you are rotating because a key is suspected compromised, that window matters: rotate, then also revoke the outstanding tokens rather than waiting them out.

A verifier written like this will fail the moment you rotate:

// Wrong: picks the first key and ignores the token's kid
const { keys } = await fetch(jwksUri).then((r) => r.json())
await jwtVerify(token, await importJWK(keys[0]))

Use a JWKS client that selects by kid and refetches on a miss:

import { createRemoteJWKSet, jwtVerify } from 'jose'
const jwks = createRemoteJWKSet(new URL('https://acme.oauth.work/.well-known/jwks.json'))
const { payload } = await jwtVerify(token, jwks, { issuer: 'https://acme.oauth.work' })
  • On a schedule, if your compliance program calls for one.
  • When someone with access to the platform’s key material leaves.
  • Immediately on suspected compromise — followed by revoking live tokens and reviewing the audit log for what the key was used for.

Rotation is a normal operation, not an outage: nothing needs to be re-signed, and no verifier that selects by kid needs to be told it happened.

A tenant’s credentials are signed by that tenant’s key and resolve through its did:web document, so rotation is visible to credential verifiers through the same mechanism — the DID document lists the org’s keys, and a credential names the one that signed it. See verifiable credentials.