Skip to main content
Every write and every profile read on the signed data-plane carries an RFC 9421 Ed25519 HTTP Message Signature under your Trust-List-published key. The census reconstructs the exact same signature base and verifies provenance (the key is yours) and accreditation (your DID is trusted) before it does anything else. Get this one thing right and the rest of the integration is ordinary JSON. This page is the canonical reference for computing the two headers — Signature-Input and Signature — in four languages. Every snippet below was executed against the hosted sandbox and returns a real 2xx; see Verify your signer.
In production the @openchildsafety/ocss SDK’s signRequest (TypeScript) and the @phosra/gatekeeper SDK do this for you. The four-language recipe here is for server-side signers in other stacks (Python/Go services, edge workers, CI) and for understanding exactly what the wire looks like. Ed25519 signing keys are secret — keep them server-side, never ship a seed to a browser or mobile client.

The covered components

OCSS signs a closed, ordered set of components. Nothing else is covered, and the order is literal — the verifier reconstructs the base in exactly this sequence:
#ComponentValue
1@methodthe HTTP method, e.g. POST
2@target-urithe full absolute URL, e.g. https://…/api/v1/enforcement-endpoints
3ocss-spec-versionthe value of the OCSS-Spec-Version header — always OCSS-v1.0-pre
4content-digestonly when the request has a body — the Content-Digest header value
A GET (a profile poll) covers components 1–3. Any request with a body (bind, rotate, confirm) adds component 4. There are no other permutations.
Three traps sink most first-time signers:
  1. Order is literal. @method, @target-uri, ocss-spec-version, then content-digest. Re-ordering the covered list changes the base and fails verification.
  2. Base64 is standard, not URL-safe. The Signature value and the Content-Digest value both use padded standard base64 (+///=). Only the DID key material elsewhere uses base64url — not these two.
  3. No trailing newline after @signature-params. Every covered line ends in \n; the final @signature-params line has no trailing newline.

The signature base

The base is built per RFC 9421 §2.5. For a POST with a body it looks exactly like this (the \n are real newlines):
The exact bytes that get signed
"@method": POST
"@target-uri": https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints
"ocss-spec-version": OCSS-v1.0-pre
"content-digest": sha-256=:NIK7mwcaIj22qhAF1stpRE6wKwKO4nZMK0PqGsjdT5A=:
"@signature-params": ("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"
You then Ed25519-sign those UTF-8 bytes and emit four headers:
OCSS-Spec-Version: OCSS-v1.0-pre
Content-Digest: sha-256=:NIK7mwcaIj22qhAF1stpRE6wKwKO4nZMK0PqGsjdT5A=:
Signature-Input: ocss=("@method" "@target-uri" "ocss-spec-version" "content-digest");created=1783315514;keyid="did:ocss:loopline#2026-06";alg="ed25519"
Signature: ocss=:qAR6e0phugzYAz4APa7xY309kOeKeBQm00…:
  • Content-Digestsha-256=:<standard-base64 of SHA-256(body)>:. The digest is over the exact request-body bytes you transmit. Serialize your JSON once, then hash and send those same bytes — if you re-serialize, the digest won’t match. (Omit this header entirely on a GET.)
  • Signature-Input — the label ocss= followed by the same (...) params list used in the @signature-params base line. created is a Unix timestamp; the census rejects signatures skewed more than a few minutes, so use a monotonic clock, not a hard-coded value.
  • Signature — the label ocss= wrapping the base64 signature between colons: ocss=:<sig>:.

Copy-paste signers

Pick your stack. All four produce byte-identical headers for the same inputs and all four are verified below against the live sandbox.
import base64, hashlib, time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

SPEC_VERSION = "OCSS-v1.0-pre"

def _b64url_decode(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def sign_request(method: str, target_uri: str, key_id: str, seed_b64url: str,
                 body: bytes | None = None, created: int | None = None) -> dict[str, str]:
    """Return the OCSS RFC 9421 headers. `seed_b64url` is your 32-byte Ed25519 seed."""
    created = created or int(time.time())
    sk = Ed25519PrivateKey.from_private_bytes(_b64url_decode(seed_b64url))

    headers = {"OCSS-Spec-Version": SPEC_VERSION}
    covered = ["@method", "@target-uri", "ocss-spec-version"]
    values = {"ocss-spec-version": SPEC_VERSION}

    if body:
        cd = "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
        headers["Content-Digest"] = cd
        values["content-digest"] = cd
        covered.append("content-digest")

    quoted = " ".join(f'"{c}"' for c in covered)
    params = f'({quoted});created={created};keyid="{key_id}";alg="ed25519"'
    headers["Signature-Input"] = "ocss=" + params

    lines = []
    for c in covered:
        v = method if c == "@method" else target_uri if c == "@target-uri" else values[c]
        lines.append(f'"{c}": {v}')
    base = "\n".join(lines) + "\n" + f'"@signature-params": {params}'

    sig = sk.sign(base.encode())
    headers["Signature"] = "ocss=:" + base64.b64encode(sig).decode() + ":"
    return headers

# Usage — sign and send with requests (or urllib):
# import json, requests
# SEED  = "bG9vcGxpbmUBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE"   # sandbox "loopline" test seed
# KEYID = "did:ocss:loopline#2026-06"
# url  = "https://phosra-api-sandbox-production.up.railway.app/api/v1/enforcement-endpoints"
# body = json.dumps({"audience_did": "did:ocss:loopline",
#                    "child_ref": "child:5ba0d00c-0000-4000-8000-0000000000c1",
#                    "window_seconds": 3600}).encode()
# h = sign_request("POST", url, KEYID, SEED, body); h["Content-Type"] = "application/json"
# print(requests.post(url, data=body, headers=h).status_code)   # -> 201
The curl + openssl path needs OpenSSL 3.0 or newer (the first release with raw Ed25519 pkeyutl -rawin). LibreSSL and OpenSSL 1.1 cannot sign Ed25519 from the command line — use the Python, Go, or TypeScript signer there.

Verify your signer

The fastest way to know your base is byte-correct is to sign a real request against the hosted sandbox and watch for a 2xx. The sandbox emitter loopline signs with a deterministic test seed (the slug bytes loopline right-padded with 0x01 to 32 bytes) — it is a test key, valid only in the sandbox. Use it to smoke-test, then swap in your real Trust-List seed. A 401 signature_invalid means the base you signed doesn’t match what the census rebuilt — check the three traps above (component order, standard vs URL-safe base64, the trailing-newline rule). A 403 means the signature verified but your DID isn’t accredited (or the consent-first gate isn’t satisfied). The full self-serve loop is:
1

Mint test consent (no roster edit)

A signed POST /sandbox/consent-attestations satisfies the §4.2.2 consent-first precondition for a seeded sandbox child and returns its target_ref. See Mint test consent.
2

Bind

A signed POST /enforcement-endpoints with that child_ref returns 201 and your endpoint_id_label. See Bind an endpoint.
3

Poll

A signed GET /enforcement-profiles/{label} returns 200 with the router-signed profile. See Fetch the signed profile.
These signers are proven, not aspirational. The Python, Go, and curl+openssl recipes on this page were each run end-to-end against https://phosra-api-sandbox-production.up.railway.app and returned the real 201/200 responses shown across the data-plane reference pages. If a snippet here ever fails to verify, it is a bug in the docs — tell us.

What the census does with it

On receipt the census:
  1. Parses Signature-Input, rebuilds the covered base in the fixed order, and re-hashes the body to check Content-Digest.
  2. Resolves keyid to a public key on the OCSS Trust List and verifies the Ed25519 signature over the base.
  3. Checks that your DID’s accreditation tier and the request’s band/scope are permitted, plus any endpoint-specific precondition (e.g. an active consent attestation for a bind).
Only then does it process the request. Signature failures are 401; trust/accreditation failures are 403; neither leaks whether the underlying resource exists (§9.3(a) fail-closed). See Errors for the full catalog.