Skip to content

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 over vault.decrypted_secrets (only issuer_signing_key:% names), readable only by credential_signeranon, authenticated, and service_role are explicitly revoked;
  • the minimal grants the signer needs: SELECT on issuer_profile / issuer_signing_key, SELECT/INSERT on credential_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>';
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.

Terminal window
# Platform default issuer
curl -sS -X POST "$API/workflow/credentials/issuers" \
-H "authorization: Bearer $PLATFORM_ADMIN_TOKEN" \
-H 'content-type: application/json' -d '{}'
# Per-organization issuer
curl -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):

Terminal window
curl -sS "$API/credentials/issuers/<profile-id>/jwks.json" # { "keys": [ … ] }
curl -sS "$API/credentials/issuers/<profile-id>" # OB 3.0 Profile doc

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 state
SELECT id AS kid, status, rotated_at, created_at_us
FROM issuer_signing_key WHERE issuer_profile_id = '<profile-id>' ORDER BY created_at_us;
-- Audit: signatures emitted
SELECT credential_id, signing_key_id, signed_at FROM credential_sign_event
WHERE issuer_profile_id = '<profile-id>' ORDER BY signed_at DESC LIMIT 50;

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 to holder_signing_key:%, readable only by holder_signer (anon/authenticated/service_role revoked);
  • grants: SELECT on holder_signing_key, SELECT/INSERT on presentation_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):

Terminal window
curl -sS -X POST "$API/workflow/credentials/holder/keys" -H "authorization: Bearer $LEARNER_TOKEN"

Verify discovery (public, unauthenticated):

Terminal window
curl -sS "$API/credentials/holders/<person-id>/jwks.json" # { "keys": [ … ] }

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.

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.