Skip to main content
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. 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 real Trust List. In production your DID arrives via accreditation instead of self-register, but the mint/rotate call is identical: same endpoint, same signed shape, same rotation semantics.

Before you start

Install the protocol SDK — it is published to npm (@openchildsafety/ocss, current 0.1.3):
npm install @openchildsafety/ocss
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"
1

Get a sandbox DID and signing key

Rotation needs an identity that signs. POST /api/v1/advisors/self-register puts a fresh did:ocss:<slug> on the sandbox Trust List, bound to a public key you generate. Keep the seed private; publish only the public half.
import { 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 — note key_id: the census binds your key under a bare kid equal to the current UTC month. That is the keyID you sign with everywhere below.
{
  "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_id the census returns. A self-registered DID binds under did:ocss:<slug>#<YYYY-MM>; signing with any other kid fails 401 (kid-not-in-entry). Do not invent a bespoke kid — read it back from the registration response.
2

Mint your endpoint — the credential you will later rotate

POST /api/v1/platforms/{did}/endpoints mints your endpoint and returns the two secrets exactly once. The request is RFC-9421 signed with the key from step 1 — signRequest covers the request line and a Content-Digest of the body.
TypeScript
import { 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"],
);
Live 201v1 of the credential pair:
{
  "capabilities": ["content_rating", "addictive_pattern_block"],
  "connect_secret": "6NtItJOQmQWEzleAJ5Dl26mLAekhjjHmct_QZx1_ohI",
  "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect",
  "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25",
  "endpoint_id_label": "PxEUb-CDZR3OhqqAG47PBKPMrsIlreXe0oGzvElrUks",
  "rotated_at": "2026-07-06T11:25:27Z"
}
connect_secret and endpoint_id_label are 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_id is the stable, loggable UUID — it is not a secret and does not change when you rotate.
3

Rotate — 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. Your endpoint_id is preserved; the old connect_secret and endpoint_id_label stop 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 endpoint
Live 201v2, the rotated pair (compare every field to v1 above):
{
  "capabilities": ["content_rating", "addictive_pattern_block"],
  "connect_secret": "pnVMLkBCBtMNgInBUaTTfI-Q-DzXzM4QbdGpj9pJN3w",
  "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect",
  "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25",
  "endpoint_id_label": "oiFMwdVWD8vv3bKf3NV7oO-SmR-qhXz-YzyZnmFYjNU",
  "rotated_at": "2026-07-06T11:25:27Z"
}
Fieldv1v2Rotated?
connect_secret6NtItJOQ…pnVMLkBC…✅ new
endpoint_id_labelPxEUb-CD…oiFMwdVW…✅ 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.
FieldTypeNotes
connect_urlstringAbsolute base URL of your POST /api/ocss/connect receiver. Re-minting the same value rotates; a different value registers a distinct endpoint. HTTPS required outside the sandbox.
capabilitiesstring[]Optional declared capability slugs. You may keep them identical across a rotate.
StatusWhen
201Rotated. New connect_secret + endpoint_id_label; same endpoint_id.
401Signature invalid or wrong kid — sign with the key_id from self-register.
404The signed caller DID does not match {did} in the path (no existence leak). Self-scope only.
4

Deploy 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/gatekeeper env contract changes:
# .env — replace with the v2 values from the rotate 201
PHOSRA_ENDPOINT_ID=oiFMwdVWD8vv3bKf3NV7oO-SmR-qhXz-YzyZnmFYjNU   # new endpoint_id_label
PHOSRA_CONNECT_SECRET=pnVMLkBCBtMNgInBUaTTfI-Q-DzXzM4QbdGpj9pJN3w # new connect_secret
Because your endpoint_id, DID, and signing key are unchanged, already-established connections keep working — a rotate re-keys the inbound channel without tearing down the family links you already hold. There is no enforcement gap.

The whole flow at a glance

#StepCallLive result
1Get DID + keyPOST /advisors/self-registerdid:ocss:dx-rotate-dab69f, kid 2026-07, provisional
2Mint (v1)POST /platforms/{did}/endpointssecret 6NtItJOQ…, endpoint 5b4ed680…
3Rotate (v2)POST /platforms/{did}/endpoints (same connect_url)secret pnVMLkBC…, same endpoint 5b4ed680…
4Deploy v2swap PHOSRA_* envold 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.