Rotate a compromised key
A secret leaked — it landed in a log, a screenshot, a committed .env. The fix is not "delete and
start over"; it is rotate: mint a new secret, invalidate the old one, and keep the integration
running. In Phosra a platform's inbound credential pair — the connect_secret (the HMAC key that
authenticates deliveries to your webhook) and the endpoint_id_label (your registration name) — is
rotated by re-minting your endpoint. Re-minting the same (did, connect_url) pair issues a
fresh pair and retires the old one, while your stable endpoint_id never changes.
flowchart LR
A["POST /advisors/self-register<br/>get a sandbox DID + key"] --> B["POST /platforms/{did}/endpoints<br/>mint — connect_secret v1"]
B --> C["leak detected"]
C --> D["POST /platforms/{did}/endpoints<br/>re-mint SAME connect_url"]
D --> E["connect_secret v2 live<br/>v1 dead · endpoint_id unchanged"]Every request and response below is verbatim live output, captured against
https://phosra-api-sandbox-production.up.railway.app. The self-register step is plain curl; the
mint and rotate are RFC-9421 signed, so they run through the
@openchildsafety/ocss SDK rather than raw curl (there is no phosra_ API
key on this path — the census identifies you by signature).
Sandbox-first. Self-registration is sandbox-only (SANDBOX_MODE=true) — nothing here touches a
production Trust List. Production identity, governance, and credential issuance remain roadmap;
validate rotation semantics in a bounded design-partner pilot before depending on them.
Before you start
Install the protocol SDK — it is published to npm (@openchildsafety/ocss,
current 0.1.5):
npm install @openchildsafety/[email protected]
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"- 1Get a sandbox DID and signing key
Rotation needs an identity that signs.
POST /api/v1/advisors/self-registerputs a freshdid:ocss:<slug>on the sandbox Trust List, bound to a public key you generate. Keep the seed private; publish only the public half.typescriptimport { ed25519PublicFromSeed, b64urlRaw } from "@openchildsafety/ocss"; import crypto from "node:crypto"; const BASE = "https://phosra-api-sandbox-production.up.railway.app"; const slug = "dx-rotate-" + crypto.randomBytes(3).toString("hex"); const did = `did:ocss:${slug}`; const seed = crypto.randomBytes(32); // KEEP SECRET (Uint8Array 32) const publicKeyB64Url = b64urlRaw(ed25519PublicFromSeed(seed)); const reg = await ( await fetch(`${BASE}/api/v1/advisors/self-register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ did, name: "DX Rotate Demo", public_key_b64url: publicKeyB64Url }), }) ).json(); // The census binds your key under a bare kid = current UTC month: const keyID = reg.key_id; // e.g. "did:ocss:dx-rotate-…#2026-07"Live
200— notekey_id: the census binds your key under a bare kid equal to the current UTC month. That is thekeyIDyou sign with everywhere below.json{ "advisor_id": "bf7c4925-3977-4546-a21d-5f5974be0ac4", "did": "did:ocss:dx-rotate-dab69f", "key_id": "did:ocss:dx-rotate-dab69f#2026-07", "kid": "2026-07", "published_key_x": "CSm9bCM4QLqBv4j0VK7jLC9BWSXJP-DaTnUemYnR8xk", "trust_tier": "provisional" }Sign with exactly the
key_idthe census returns. A self-registered DID binds underdid:ocss:<slug>#<YYYY-MM>; signing with any other kid fails401(kid-not-in-entry). Do not invent a bespoke kid — read it back from the registration response. - 2Mint your endpoint — the credential you will later rotate
POST /api/v1/platforms/{did}/endpointsmints your endpoint and returns the two secrets exactly once. The request is RFC-9421 signed with the key from step 1 —signRequestcovers the request line and aContent-Digestof the body.typescriptimport { signRequest } from "@openchildsafety/ocss"; async function mint(connectUrl: string, capabilities: string[]) { const bodyText = JSON.stringify({ connect_url: connectUrl, capabilities }); const targetURI = `${BASE}/api/v1/platforms/${encodeURIComponent(did)}/endpoints`; const headers = signRequest({ method: "POST", targetURI, body: new TextEncoder().encode(bodyText), keyID, seed, created: Math.floor(Date.now() / 1000), }); headers["Content-Type"] = "application/json"; const res = await fetch(targetURI, { method: "POST", headers, body: bodyText }); return res.json(); } const v1 = await mint( "https://dx-rotate.example.com/webhooks/ocss-connect", ["content_rating", "addictive_pattern_block"], );Example sandbox
201— v1 of the credential pair (secrets redacted):json{ "capabilities": ["content_rating", "addictive_pattern_block"], "connect_secret": "REDACTED_SANDBOX_CONNECT_SECRET_V1", "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect", "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25", "endpoint_id_label": "REDACTED_SANDBOX_ENDPOINT_ID_LABEL_V1", "rotated_at": "2026-07-06T11:25:27Z" }connect_secretandendpoint_id_labelare each an unprefixed 43-character base64url secret returned only in this body — the census stores only their SHA-256 digests. Persist both to a secret manager immediately. If either leaks, you rotate (next step).endpoint_idis the stable, loggable UUID — it is not a secret and does not change when you rotate. - 3Rotate — re-mint the same connect_url
Now the leak. To rotate, call the same mint endpoint with the same
connect_url. The census recognises the(did, connect_url)pair and re-issues both secrets — this is the OCSS Trust Framework §3.5 re-issue path. Yourendpoint_idis preserved; the oldconnect_secretandendpoint_id_labelstop verifying.typescript// Same did, same connect_url → rotation (NOT a second endpoint). const v2 = await mint( "https://dx-rotate.example.com/webhooks/ocss-connect", ["content_rating", "addictive_pattern_block"], ); console.log(v2.connect_secret !== v1.connect_secret); // true — new secret console.log(v2.endpoint_id_label !== v1.endpoint_id_label); // true — new label console.log(v2.endpoint_id === v1.endpoint_id); // true — same endpointExample sandbox
201— v2, the rotated pair (compare every field to v1 above):json{ "capabilities": ["content_rating", "addictive_pattern_block"], "connect_secret": "REDACTED_SANDBOX_CONNECT_SECRET_V2", "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect", "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25", "endpoint_id_label": "REDACTED_SANDBOX_ENDPOINT_ID_LABEL_V2", "rotated_at": "2026-07-06T11:25:27Z" }Field v1 v2 Rotated? connect_secretREDACTED_V1REDACTED_V2✅ new endpoint_id_labelREDACTED_V1REDACTED_V2✅ new endpoint_id5b4ed680…5b4ed680…❌ stable The leaked v1 secret is now dead. Any inbound delivery signed with it fails the HMAC check fail-closed; only v2 verifies.
- 4Deploy the new secret
Rotation is only complete once your gatekeeper is running on v2. Swap the two
PHOSRA_*values in your secret manager and restart — nothing else in the@phosra/gatekeeperenv contract changes:bash# .env — replace with the v2 values from the rotate 201 PHOSRA_ENDPOINT_ID=REDACTED_SANDBOX_ENDPOINT_ID_LABEL_V2 PHOSRA_CONNECT_SECRET=REDACTED_SANDBOX_CONNECT_SECRET_V2In the sandbox model, your stable
endpoint_id, DID, and signing key remain unchanged while the inbound credential pair rotates. Validate this behavior in your design-partner pilot before depending on it for a production migration.
The whole flow at a glance
| # | Step | Call | Live result |
|---|---|---|---|
| 1 | Get DID + key | POST /advisors/self-register | did:ocss:dx-rotate-dab69f, kid 2026-07, provisional |
| 2 | Mint (v1) | POST /platforms/{did}/endpoints | secret 6NtItJOQ…, endpoint 5b4ed680… |
| 3 | Rotate (v2) | POST /platforms/{did}/endpoints (same connect_url) | secret pnVMLkBC…, same endpoint 5b4ed680… |
| 4 | Deploy v2 | swap PHOSRA_* env | old secret dead, no enforcement gap |
Next steps
Platform registration
The full endpoint-mint contract, the six PHOSRA_* env vars, and the webhook verify side.
Onboarding — get a sandbox DID
Self-register, read back your bound kid, and what provisional tier can and cannot do.
Back off and retry a 429
The other half of resilient auth — a leaked-then-rotated key often surfaces first as a 401.
Error reference
signature_invalid, kid-not-in-entry, and every other auth failure documented in one place.