Rensei docs
Audit

Audit JWKS

.well-known/audit-keys JWKS.

The /.well-known/audit-keys/{workspace_id} endpoint publishes the public JWKS (JSON Web Key Set) for entries produced by the current audit append path. Those entries carry an Ed25519 signature and a signing_key_id; retained legacy entries can be unsigned. A holder can fetch the referenced public key without authentication and run the signature check locally.

Signature presence is not verification. The verifier must recompute the entry hash, select the referenced key, and check the Ed25519 signature over the raw hash bytes. Hash-link checks and any Merkle or timestamp checks are separate results. Successful verification of a supplied entry or segment does not establish capture, full-history completeness, retention outside the segment, truth at capture, or external anchoring.

Endpoint

GET /.well-known/audit-keys/{workspace_id}

No authentication required. This endpoint is fully public with CORS open to all origins.

The {workspace_id} is the workspace identifier returned in authenticated audit or receipt metadata. Onboarding and audit surfaces that present a public JWKS link use the same identifier; there is no dedicated Settings > Security > Audit Keys page.

Also accepts a .json suffix on the same route:

GET /.well-known/audit-keys/{workspace_id}.json

Response headers

HeaderValue
Content-Typeapplication/jwk-set+json; charset=utf-8
Cache-Controlpublic, max-age=300, stale-while-revalidate=3600
ETagDigest of current key set (supports conditional GET)
Access-Control-Allow-Origin*

The 5-minute max-age plus 1-hour stale-while-revalidate allows auditing tools to cache keys aggressively while still picking up key rotations promptly.

JWKS response

{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "alg": "EdDSA",
      "use": "sig",
      "kid": "key_01abc...",
      "x": "base64url-encoded-public-key",
      "rensei:workspace_id": "ws_01abc...",
      "rensei:created_at": "2026-01-01T00:00:00Z",
      "rensei:revoked_at": null
    }
  ]
}

Standard RFC 7517 fields

FieldValueDescription
ktyOKPKey type: Octet Key Pair (EdDSA family)
crvEd25519Curve: Edwards Curve 25519
algEdDSAAlgorithm: Edwards-curve Digital Signature Algorithm
usesigKey usage: digital signatures
kidstringKey ID - matches the entry's separate signing_key_id field
xbase64urlPublic key material

Rensei extension members

FieldDescription
rensei:workspace_idThe workspace this key belongs to
rensei:created_atISO 8601 timestamp when the key was provisioned
rensei:revoked_atISO 8601 timestamp when the key was revoked, or null if still active

A non-null rensei:revoked_at means the key is no longer selected for new current-path entries. A retained entry that references the key can still be checked against it, but it is verified only when the entry-hash and signature checks succeed and the key timing is appropriate for the entry.

Verifying an audit event signature

Current-path entries carry a base64-encoded Ed25519 signature and a separate signing_key_id. The signature is not a JWS envelope. Verification steps:

Check that verification material exists. If signature or signing_key_id is absent, report the entry as legacy unsigned rather than verified.

Recompute the entry hash. Canonicalize workspace_id, sequence_number, event_type, actor_id, payload, occurred_at, and prev_hash, then compute SHA-256. The result must equal entry_hash.

Fetch the JWKS from /.well-known/audit-keys/{workspace_id}. Find the key whose kid equals signing_key_id, and confirm its creation/revocation timestamps are consistent with occurred_at.

Verify the Ed25519 signature using the key's x value against the raw 32-byte SHA-256 digest, not the JSON text.

Check segment linkage separately. For a supplied contiguous segment, each entry's prev_hash must equal the prior entry's entry_hash. This detects a broken supplied segment; it does not prove that earlier or later entries were supplied.

Treat anchors as a separate optional check. A Merkle inclusion result is not an external timestamp. Only a configured anchor response with the implemented checks can add timestamp evidence, and the current public sample is not externally anchored.

Verification example (TypeScript)

interface AuditEvent {
  workspace_id: string;
  sequence_number: number;
  event_type: string;
  actor_id: string;
  payload: Record<string, unknown>;
  occurred_at: string;
  prev_hash: string;
  entry_hash: string;
  signature: string | null;      // base64 Ed25519 signature
  signing_key_id: string | null; // matches JWK kid
}

interface JwkKey {
  kty: string;
  crv: string;
  x: string;
  kid: string;
  'rensei:revoked_at': string | null;
}

async function fetchPublicKey(workspaceId: string, kid: string): Promise<JwkKey> {
  const res = await fetch(`https://app.rensei.ai/.well-known/audit-keys/${workspaceId}`);
  const jwks = await res.json();
  const key = jwks.keys.find((k: JwkKey) => k.kid === kid);
  if (!key) throw new Error(`Key ${kid} not found in JWKS`);
  return key;
}

async function verifyAuditEvent(
  event: AuditEvent,
): Promise<boolean> {
  if (!event.signature || !event.signing_key_id) return false;

  const canonicalEntry = {
    workspace_id: event.workspace_id,
    sequence_number: event.sequence_number,
    event_type: event.event_type,
    actor_id: event.actor_id,
    payload: event.payload,
    occurred_at: event.occurred_at,
    prev_hash: event.prev_hash,
  };
  const sortKeys = (value: unknown): unknown => {
    if (Array.isArray(value)) return value.map(sortKeys);
    if (value && typeof value === 'object') {
      return Object.fromEntries(
        Object.keys(value as Record<string, unknown>)
          .sort()
          .map((key) => [
            key,
            sortKeys((value as Record<string, unknown>)[key]),
          ]),
      );
    }
    return value;
  };
  const canonical = JSON.stringify(sortKeys(canonicalEntry));
  const digest = new Uint8Array(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(canonical)),
  );
  const recomputedHash = Array.from(digest, (byte) =>
    byte.toString(16).padStart(2, '0'),
  ).join('');
  if (recomputedHash !== event.entry_hash) return false;

  const jwk = await fetchPublicKey(event.workspace_id, event.signing_key_id);

  const publicKey = await crypto.subtle.importKey(
    'jwk',
    { kty: 'OKP', crv: 'Ed25519', x: jwk.x, alg: 'EdDSA' },
    { name: 'Ed25519' },
    false,
    ['verify'],
  );

  const signatureBytes = Uint8Array.from(
    atob(event.signature),
    (character) => character.charCodeAt(0),
  );
  return crypto.subtle.verify('Ed25519', publicKey, signatureBytes, digest);
}

Use the public redacted sample to test canonicalization and signature verification against fixed, published bytes. A successful return from verifyAuditEvent covers that entry's hash and signature only; check segment linkage and any anchor evidence separately.

Conditional GET (ETags)

Use If-None-Match to avoid re-downloading the JWKS when it hasn't changed:

# First request: get ETag
curl -I "https://app.rensei.ai/.well-known/audit-keys/ws_01abc..."
# HTTP/2 200
# etag: "a1b2c3d4e5f6"

# Subsequent request: conditional GET
curl "https://app.rensei.ai/.well-known/audit-keys/ws_01abc..." \
  -H 'If-None-Match: "a1b2c3d4e5f6"'
# HTTP/2 304 Not Modified (no body - use cached JWKS)

Key rotation

When an operator rotates a workspace signing key:

  1. The old key is revoked - its rensei:revoked_at is set to the rotation timestamp.
  2. The replacement key is provisioned - it appears in the JWKS with rensei:revoked_at: null.
  3. New current-path entries use the replacement key. Retained signed entries continue to reference the key that signed them.

Both active and revoked keys appear in the JWKS response so a verifier can check retained signed entries from before a rotation. Retained legacy unsigned entries remain explicitly unsigned.

On this page