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}.jsonResponse headers
| Header | Value |
|---|---|
Content-Type | application/jwk-set+json; charset=utf-8 |
Cache-Control | public, max-age=300, stale-while-revalidate=3600 |
ETag | Digest 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
| Field | Value | Description |
|---|---|---|
kty | OKP | Key type: Octet Key Pair (EdDSA family) |
crv | Ed25519 | Curve: Edwards Curve 25519 |
alg | EdDSA | Algorithm: Edwards-curve Digital Signature Algorithm |
use | sig | Key usage: digital signatures |
kid | string | Key ID - matches the entry's separate signing_key_id field |
x | base64url | Public key material |
Rensei extension members
| Field | Description |
|---|---|
rensei:workspace_id | The workspace this key belongs to |
rensei:created_at | ISO 8601 timestamp when the key was provisioned |
rensei:revoked_at | ISO 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:
- The old key is revoked - its
rensei:revoked_atis set to the rotation timestamp. - The replacement key is provisioned - it appears in the JWKS with
rensei:revoked_at: null. - 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.
Related pages
- Audit API overview - event queries, explicit verification, proof limits, and optional anchoring
- Security: Audit Trail - architecture and compliance guidance
- A2A Agent Card - the other
.well-knowndiscovery endpoint