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 usesper_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 noX-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
| Header | Always | Value |
|---|---|---|
X-Waxell-AXID | when AXID is on | a 60-second JWT presenting the run's identity for this call |
X-Waxell-Workflow-Id | yes | the run's workflow id (also in the token's run.id neighbourhood) |
X-Waxell-Agent-Name | yes | the agent name |
X-Waxell-Tenant-Id | yes | your workspace id, the tenant_id claim |
X-Waxell-Sub-User-Token | with per_user_auth and an end user | the 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" }
}
| Claim | Meaning |
|---|---|
iss | Your 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. |
sub | The 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. |
jti | This presentation's id. New on every call; the token is never reused. |
axid_jti | The id of the run's root AXID (equals sub without the prefix). |
aud | domain:<name> of the domain you registered. Check it. |
exp − iat | 60 seconds. |
tenant_id | Your workspace id. Must equal the id in the JWKS URL you fetched. |
agent | Name, pushed version, and a hash of the agent's manifest as registered. |
run.id | The run on the Runs page; wax runs show <id>. |
scope | domain:<name>.* for each domain the agent declared; * when it declared none. |
act | The 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_hash | Delegation. 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
- Parse the header. Require
alg == "EdDSA"andtyp == "axid+jwt". - Fetch the JWKS, select the key by
kid, verify the signature. - Check
exp(allow a few seconds of skew) andtenant_id. - Check
audcontainsdomain:<your domain name>. - Check
call.actionis the action being invoked. - Authorize on
agent.name,agent.version,act, andscopeas your policy requires. Storesubandcall.idon 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
axidclaim equals the AXID'ssub, and - the AXID's
act.subequals the end-user token'ssub_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.
Related
- Verifying End-User Tokens: the person's identity on the same call
- Register a Domain: where the header arrives
- Signals HTTP reference: starting a run with
_sub_user_identity