Skip to main content

Verifying Agent Identity (AXID)

An end-user token tells your service who the person is. The Agent Execution Identity (AXID) tells it what is acting: which agent, at which version, in which run, on whose behalf, and delegated by which parent run. Waxell mints one AXID per run and presents it on every domain call as a short-lived, Ed25519-signed token in the X-Waxell-AXID header. You verify it offline against your workspace's public keys. Nothing shared, nothing to rotate on your side.

The two headers are designed to be read together. The end-user token's axid claim names the run identity that acted for the person, and the AXID's act claim names the person the run acted for.

When it is sent​

Always, on every workspace:

  • every run (any framework, any execution tier) is minted an AXID when it starts, and
  • every domain call the run makes carries X-Waxell-AXID, whether or not the domain uses per_user_auth.

wax axid show prints your JWKS URL and the active key.

Turning it off​

Two levels, and the more specific one wins:

  • One agent. In its waxell.yaml, identity: { axid: false }. That agent's runs mint no identity and its domain calls carry no X-Waxell-AXID; every other agent is unaffected. Use it for agents whose downstream systems never verify the header. identity: { axid: true } forces it on for one agent in a workspace that has it off.
  • The whole workspace. Switched off on request; then no run mints an identity unless its agent says axid: true.

Either way, everything else about the call is unchanged, including the sub-user identity headers, which follow the domain's per_user_auth.

Headers​

HeaderAlwaysValue
X-Waxell-AXIDwhen AXID is ona 60-second JWT presenting the run's identity for this call
X-Waxell-Workflow-Idyesthe run's workflow id (also in the token's run.id neighbourhood)
X-Waxell-Agent-Nameyesthe agent name
X-Waxell-Tenant-Idyesyour workspace id, the tenant_id claim
X-Waxell-Sub-User-Tokenwith per_user_auth and an end userthe person's token; its axid claim equals this AXID's sub

Token format​

A compact JWS. Header:

{ "alg": "EdDSA", "typ": "axid+jwt", "kid": "axid-3f9c2a1b7e4d" }

Refuse anything that is not alg: EdDSA with typ: axid+jwt.

Claims​

{
"iss": "https://api.waxell.dev/api/v1/tenants/8e9d…",
"aud": ["domain:things"],
"sub": "axid:5f1a9c…",
"jti": "2b7e…",
"axid_jti": "5f1a9c…",
"iat": 1760390000,
"exp": 1760390060,
"tenant_id": "8e9d…",
"agent": { "name": "reviewer", "version": "1.2.0", "spec_hash": "a3c1…", "tenant_agent_id": null },
"run": { "id": "4471", "workflow": "review", "started_at": 1760389998 },
"scope": ["domain:things.*"],
"act": { "iss": "tenant:acme", "sub": "u_42", "email": "ada@example.com", "roles": ["reviewer"], "permissions": [], "assurance": "tenant_asserted" },
"parent_axid": null,
"chain_depth": 1,
"chain_hash": "9d0e…",
"call": { "id": "7c2a…", "domain": "things", "action": "publish" }
}
ClaimMeaning
issYour workspace's identity prefix: the API origin + /api/v1/tenants/<your tenant id>. Append /.well-known/axid-jwks.json and you have the keys that verify this token. Check it equals the prefix you fetched the JWKS from.
subThe run's identity, axid:<hex>. Durable: the same value appears on the run's spans, its lineage edges, its domain call records and the person's activity log. Store this on the rows your service changes.
jtiThis presentation's id. New on every call; the token is never reused.
axid_jtiThe id of the run's root AXID (equals sub without the prefix).
auddomain:<name> of the domain you registered. Check it.
exp − iat60 seconds.
tenant_idYour workspace id. Must equal the id in the JWKS URL you fetched.
agentName, pushed version, and a hash of the agent's manifest as registered.
run.idThe run on the Runs page; wax runs show <id>.
scopedomain:<name>.* for each domain the agent declared; * when it declared none.
actThe person the run acts for, or null for a system-triggered run. assurance is tenant_asserted when your system named the user in the signal payload, idp_verified when Waxell verified an IdP identity.
parent_axid, chain_depth, chain_hashDelegation. A run spawned by another run carries its parent's sub; depth counts from 1; the hash commits to the actor and the parent so a chain cannot be spliced.
call{id, domain, action} for this request. call.id is the domain call id Waxell records (wax axid trace lists them).

Your JWKS URL​

https://<your Waxell API host>/api/v1/tenants/<workspace-id>/.well-known/axid-jwks.json

wax axid show prints it. The document lists the active key and any key still inside its rotation overlap, each with kid, kty: OKP, crv: Ed25519, x. Cache it for up to 10 minutes and refetch on an unknown kid. This is a different key set from the end-user token JWKS.

Verification checklist​

  1. Parse the header. Require alg == "EdDSA" and typ == "axid+jwt".
  2. Fetch the JWKS, select the key by kid, verify the signature.
  3. Check exp (allow a few seconds of skew) and tenant_id.
  4. Check aud contains domain:<your domain name>.
  5. Check call.action is the action being invoked.
  6. Authorize on agent.name, agent.version, act, and scope as your policy requires. Store sub and call.id on anything the call changes.

Python​

# pip install "PyJWT[crypto]>=2.8" httpx
import time, httpx, jwt

JWKS_URL = "https://api.waxell.dev/api/v1/tenants/<workspace-id>/.well-known/axid-jwks.json"
TENANT_ID = "<workspace-id>"
_cache = {"at": 0, "keys": {}}

def _keys():
if time.time() - _cache["at"] > 600 or not _cache["keys"]:
jwks = httpx.get(JWKS_URL, timeout=5).json()
_cache["keys"] = {k["kid"]: jwt.PyJWK(k).key for k in jwks["keys"]}
_cache["at"] = time.time()
return _cache["keys"]

def verify_axid(token: str, *, domain: str, action: str) -> dict:
header = jwt.get_unverified_header(token)
if header.get("alg") != "EdDSA" or header.get("typ") != "axid+jwt":
raise ValueError("not an AXID")
key = _keys().get(header.get("kid"))
if key is None:
_cache["at"] = 0
key = _keys().get(header.get("kid"))
if key is None:
raise ValueError("unknown kid")
claims = jwt.decode(token, key=key, algorithms=["EdDSA"],
audience=f"domain:{domain}", leeway=5)
if claims.get("tenant_id") != TENANT_ID:
raise ValueError("wrong tenant")
if (claims.get("call") or {}).get("action") != action:
raise ValueError("token presented for a different action")
return claims

# in your handler:
# claims = verify_axid(request.headers["X-Waxell-AXID"], domain="things", action="publish")
# row.changed_by_axid = claims["sub"]; row.changed_by_call = claims["call"]["id"]
# person = (claims.get("act") or {}).get("sub")

Node.js​

// npm i jose
import { createRemoteJWKSet, jwtVerify, decodeProtectedHeader } from "jose";

const TENANT_ID = "<workspace-id>";
const JWKS = createRemoteJWKSet(
new URL(`https://api.waxell.dev/api/v1/tenants/${TENANT_ID}/.well-known/axid-jwks.json`),
{ cooldownDuration: 600_000 },
);

export async function verifyAxid(token, { domain, action }) {
const header = decodeProtectedHeader(token);
if (header.alg !== "EdDSA" || header.typ !== "axid+jwt") throw new Error("not an AXID");
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["EdDSA"],
audience: `domain:${domain}`,
clockTolerance: 5,
});
if (payload.tenant_id !== TENANT_ID) throw new Error("wrong tenant");
if (payload.call?.action !== action) throw new Error("token presented for a different action");
return payload; // payload.sub, payload.agent, payload.act, payload.call.id
}

Reading it with the end-user token​

When the domain has per_user_auth: true and the run has an end user, the same request carries X-Waxell-Sub-User-Token. After verifying both:

  • the end-user token's axid claim equals the AXID's sub, and
  • the AXID's act.sub equals the end-user token's sub_user_id.

Reject the request if either pair disagrees. Together they prove that this specific agent run, acting for this specific person, made this specific call.

Tracing an AXID back​

From any sub you stored:

wax axid trace axid:5f1a9c…

prints the person, the agent and version, the run, every domain call that presented the identity (with the call.id you logged), the lineage edges, the person's activity entries, and any child identities a spawned run minted. The same data is at GET /api/v1/axid/<sub>/. To check a header value you received:

wax axid verify "$(cat header.txt)" --audience domain:things

Delegation​

A run that spawns another run mints the child's AXID with parent_axid set to its own sub, chain_depth increased by one, and chain_hash committing to the actor and the parent. Verify the chain by fetching the parent through wax axid trace; a verifier that only trusts depth-1 identities can refuse chain_depth > 1.

Key rotation​

Keys live 90 days and rotate with an overlap; the JWKS lists every key still valid. Refetch on an unknown kid and keep accepting any listed key.