Runbook: credential issuer keys (provisioning, custody, rotation)
The issuer-key custody layer behind the Open Badges 3.0 export
(credential-export-v1; ADR-0015/0016/0017). Private Ed25519 keys live in
Supabase Vault; the public halves are served as JWKS. This runbook covers the
operator steps the schema/migrations cannot encode: the signing role’s login
credential and the signing connection string.
Trust model (what the migration already enforces)
Section titled “Trust model (what the migration already enforces)”The credential_signing_role migration (in the regenerated baseline set) creates:
- role
credential_signer(NOLOGIN — no usable credential is ever committed); - view
public.issuer_signing_secret, a name-scoped window overvault.decrypted_secrets(onlyissuer_signing_key:%names), readable only bycredential_signer—anon,authenticated, andservice_roleare explicitly revoked; - the minimal grants the signer needs: SELECT on
issuer_profile/issuer_signing_key, SELECT/INSERT oncredential_sign_event.
The general application role may write vault secrets (encryption only, via
vault.create_secret) during provisioning, but can never decrypt them. The
decrypt path is the KeySigner seam connecting as credential_signer.
1. Give the signing role a login (operator, per environment)
Section titled “1. Give the signing role a login (operator, per environment)”Never an agent. The password is environment-owned and never committed.
ALTER ROLE credential_signer LOGIN PASSWORD '<generated-secret>';2. Wire the deploy environment
Section titled “2. Wire the deploy environment”| Variable | Purpose |
|---|---|
SIGNING_DATABASE_URL |
Connection string for the KeySigner, authenticating as credential_signer. The only place this role’s credential is used. |
CREDENTIAL_ISSUER_BASE_URL |
Externally reachable base for issuer URIs / JWKS URLs (e.g. https://app.example.org). Unset falls back to https://localhost (syntactically valid; not verifiable). Mirrors CASE_PROVIDER_BASE_URL / ONEROSTER_PROVIDER_BASE_URL. |
DATABASE_URL |
Existing app connection; used for provisioning (encrypt-only vault writes) and the public JWKS/profile reads. |
SIGNING_DATABASE_URL should point at the same database as DATABASE_URL, only
with the credential_signer user — keeping the decrypt privilege off the general
pool.
3. Provision an issuer profile + first signing key (platform admin)
Section titled “3. Provision an issuer profile + first signing key (platform admin)”Profiles are per-organization; omit organizationId for the platform default
(independent learners). Provisioning is idempotent.
# Platform default issuercurl -sS -X POST "$API/workflow/credentials/issuers" \ -H "authorization: Bearer $PLATFORM_ADMIN_TOKEN" \ -H 'content-type: application/json' -d '{}'
# Per-organization issuercurl -sS -X POST "$API/workflow/credentials/issuers" \ -H "authorization: Bearer $PLATFORM_ADMIN_TOKEN" \ -H 'content-type: application/json' -d '{"organizationId":"<org-uuid>"}'
# Mint the first signing key (also the rotation endpoint)curl -sS -X POST "$API/workflow/credentials/issuers/<profile-id>/keys" \ -H "authorization: Bearer $PLATFORM_ADMIN_TOKEN"Verify discovery (public, unauthenticated):
curl -sS "$API/credentials/issuers/<profile-id>/jwks.json" # { "keys": [ … ] }curl -sS "$API/credentials/issuers/<profile-id>" # OB 3.0 Profile doc4. Rotation
Section titled “4. Rotation”POST …/issuers/:profileId/keys mints a new active key and flips the prior key
to retired. Rotation is additive: the retired key stays in the JWKS, so
every credential signed under any past kid keeps verifying. No re-issuance.
-- Audit: keys for a profile and their stateSELECT id AS kid, status, rotated_at, created_at_usFROM issuer_signing_key WHERE issuer_profile_id = '<profile-id>' ORDER BY created_at_us;
-- Audit: signatures emittedSELECT credential_id, signing_key_id, signed_at FROM credential_sign_eventWHERE issuer_profile_id = '<profile-id>' ORDER BY signed_at DESC LIMIT 50;Holder keys (capability-b; ADR-0038)
Section titled “Holder keys (capability-b; ADR-0038)”The holder side mirrors the issuer side exactly, scoped to a person instead of an issuer
Profile: per-learner Ed25519 keys sign Verifiable Presentations, the private half in Supabase
Vault under holder_signing_key:{kid}, decrypted only by the dedicated holder_signer role. The
same credential_signing_role migration creates:
- role
holder_signer(NOLOGIN); - view
public.holder_signing_secret, name-scoped toholder_signing_key:%, readable only byholder_signer(anon/authenticated/service_rolerevoked); - grants: SELECT on
holder_signing_key, SELECT/INSERT onpresentation_sign_event.
Operator steps (per environment, never an agent — the password is environment-owned):
ALTER ROLE holder_signer LOGIN PASSWORD '<generated-secret>';| Variable | Purpose |
|---|---|
HOLDER_SIGNING_DATABASE_URL |
Connection string for the KeySigner’s holder path, authenticating as holder_signer. Points at the same database as DATABASE_URL, only with the holder_signer user — keeping the holder decrypt privilege off the general pool and off the issuer signer. |
A learner provisions/rotates their own holder key (self-service; the route keys on the authenticated principal, never a path id):
curl -sS -X POST "$API/workflow/credentials/holder/keys" -H "authorization: Bearer $LEARNER_TOKEN"Verify discovery (public, unauthenticated):
curl -sS "$API/credentials/holders/<person-id>/jwks.json" # { "keys": [ … ] }Integration test lane
Section titled “Integration test lane”tests/integration/credential-issuer.integration.test.ts exercises the full
loop (provision → JWKS → sign → external verify) against the podman-compose DB,
which ships supabase_vault. There it points SIGNING_DATABASE_URL at the
privileged integration connection (the signing logic is what is under test),
and separately asserts the role-isolation grants via catalog queries. Production
keeps SIGNING_DATABASE_URL on the dedicated credential_signer login from
step 1.
Upgrade trigger (ADR-0017)
Section titled “Upgrade trigger (ADR-0017)”When credential revocation / status-list infrastructure lands or multi-org
production issuance begins, custody moves from Vault to OpenBao transit behind the
same KeySigner seam — additive via JWKS kid rotation, never a re-issuance event.