Verifying End-User Tokens in Your Service
When an agent acts for one of your users, for example a support agent drafting a refund for Alice, your service needs to know two things on every call it receives from Waxell. The call must really come from your Waxell workspace, and it must be made on Alice's behalf. Waxell answers both with a signed, short-lived end-user token on each domain call. You verify it with your workspace's public keys. No shared secret leaves Waxell.
When a token is sent
Waxell attaches end-user headers to a domain call when both of these hold:
-
The domain opts in. The domain contract sets
per_user_auth: trueat the top level:# company.yaml
name: company
per_user_auth: true
auth: { … }
endpoint: { … }
actions: [ … ] -
The run carries an end user. The signal that started the run included
_sub_user_identityin its payload:{
"prompt": "Refund order 1042 if it qualifies",
"_sub_user_identity": {
"sub_user_id": "alice",
"email": "alice@acme.com",
"display_name": "Alice Ng",
"roles": ["support_agent"],
"permissions": ["orders:refund"]
}
}See Signal HTTP Reference for every field.
Waxell mints a fresh token for each domain call. Every call the run makes to your service carries its own token.
Headers
| Header | Contents | Use it for |
|---|---|---|
X-Waxell-Sub-User-Id | Your user's id, plain text | Logging and tracing only |
X-Waxell-Sub-User-Email | Your user's email, plain text (sent when known) | Logging and tracing only |
X-Waxell-Sub-User-Token | wax_su_v2_<kid>.<payload>.<signature> | Authorization. This is the authoritative, signed identity. |
X-Waxell-Sub-User-Id and X-Waxell-Sub-User-Email are unsigned
conveniences. Base every authorization decision on the verified claims of
X-Waxell-Sub-User-Token.
Token format
wax_su_v2_<kid>.<base64url(payload_json)>.<base64url(ed25519_signature)>
| Part | Meaning |
|---|---|
wax_su_v2_ | Fixed prefix. Reject any token that doesn't start with it. |
<kid> | Key id. Selects the public key from your JWKS. |
<payload> | Base64url (unpadded) encoding of the compact JSON claims. |
<signature> | Base64url (unpadded) Ed25519 signature. |
The signature is computed over the base64url payload string exactly as it
appears in the token, as UTF-8 bytes. It is not computed over the decoded
JSON, and not over a header.payload pair. The token is not a JWS/JWT: a
generic JWT library will not verify it. Use the short verifiers below, which
need only an Ed25519 primitive.
Claims
| Claim | Type | Meaning |
|---|---|---|
iss | string (UUID) | Your Waxell workspace (tenant) id. Check it equals your tenant id. |
sub | string | Your user's id: the sub_user_id you sent. |
email | string or null | Your user's email, if provided. |
name | string or null | Display name, if provided. |
tenant_id | string (UUID) | Same value as iss. |
aud | string | The domain name the call is for (e.g. "company"). Check it equals the domain your endpoint serves. |
iat | integer (Unix seconds) | Issued at. |
exp | integer (Unix seconds) | Expires at: iat + 300 (5 minutes). |
jti | string | Unique token id, for replay protection. |
roles | string[] | Roles you sent for the user. |
perms | string[] | Permissions you sent for the user. |
groups | string[] | The user's groups from your Waxell end-user directory, when present. |
Tokens can carry additional claims, for example attrs (the attributes you
sent) and waxell_run_id (the run making the call, useful for audit
correlation). Treat any claim you don't use as informational, and don't fail
verification because of an unrecognised claim.
Your JWKS URL
Every workspace publishes its public keys at a public, unauthenticated URL:
https://api.waxell.dev/api/v1/tenants/<tenant_uuid>/.well-known/waxell-sub-user-jwks.json
Find <tenant_uuid> with wax whoami. It's the Tenant ID line:
$ wax whoami
╭──────────── Identity ────────────╮
│ Email: you@acme.com │
│ User ID: … │
│ │
│ Tenant: Acme │
│ Slug: acme │
│ Tenant ID: 3f2b6c1e-… │
╰──────────────────────────────────╯
The response is a standard JWKS:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"kid": "v2_aBcDeFg…",
"use": "sig",
"alg": "EdDSA",
"x": "<base64url public key>"
}
]
}
Your signing key is created with your workspace's first end-user token, so the
key set fills in once your first per-user call goes out. The endpoint sends
Cache-Control: public, max-age=300. Cache the key set for up to 10 minutes.
Verification checklist
- The token starts with
wax_su_v2_and splits into exactly three.-separated parts. - The
kidis in your JWKS. If it isn't, refetch the JWKS once before rejecting (see Key rotation). - The Ed25519 signature over the payload string verifies against that key.
issequals your tenant id.audequals the domain name this endpoint serves.expis in the future. Allow a small clock skew; 30 seconds is plenty.jtihasn't been seen before (see Replay protection).
Then authorize on sub, roles, perms and groups.
Python
Uses cryptography for Ed25519 and httpx to
fetch the JWKS (pip install cryptography httpx).
"""Verify Waxell end-user tokens (X-Waxell-Sub-User-Token)."""
import base64
import json
import threading
import time
import httpx
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
TENANT_ID = "00000000-0000-0000-0000-000000000000" # from `wax whoami`
JWKS_URL = (
f"https://api.waxell.dev/api/v1/tenants/{TENANT_ID}"
"/.well-known/waxell-sub-user-jwks.json"
)
PREFIX = "wax_su_v2_"
JWKS_TTL_S = 600 # cache keys for up to 10 minutes
CLOCK_SKEW_S = 30 # tolerance on exp / iat
class TokenError(Exception):
pass
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
_jwks_lock = threading.Lock()
_jwks: dict = {}
_jwks_expires = 0.0
def _get_jwk(kid: str) -> dict:
"""Return the JWK for ``kid``; refetch the JWKS on an unknown kid."""
global _jwks, _jwks_expires
with _jwks_lock:
if kid in _jwks and time.time() < _jwks_expires:
return _jwks[kid]
resp = httpx.get(JWKS_URL, timeout=5.0)
resp.raise_for_status()
_jwks = {k["kid"]: k for k in resp.json().get("keys", [])}
_jwks_expires = time.time() + JWKS_TTL_S
if kid not in _jwks:
raise TokenError(f"unknown signing key {kid!r}")
return _jwks[kid]
_seen_jti: dict = {}
_jti_lock = threading.Lock()
def _check_replay(jti: str, exp: int) -> None:
"""Reject a jti seen before. This map is per-process — use a shared
store (e.g. Redis SET NX EX) when you run more than one instance."""
now = time.time()
with _jti_lock:
for k in [k for k, until in _seen_jti.items() if until < now]:
del _seen_jti[k]
if jti in _seen_jti:
raise TokenError("token replayed")
_seen_jti[jti] = exp + CLOCK_SKEW_S
def verify_end_user_token(token: str, *, expected_domain: str) -> dict:
"""Return the verified claims, or raise TokenError."""
if not token or not token.startswith(PREFIX):
raise TokenError("not a wax_su_v2 token")
try:
kid, payload_b64, sig_b64 = token[len(PREFIX):].split(".")
except ValueError:
raise TokenError("malformed token") from None
jwk = _get_jwk(kid)
if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
raise TokenError("unexpected key type")
# The signature covers the base64url payload STRING, exactly as received.
try:
Ed25519PublicKey.from_public_bytes(_b64url_decode(jwk["x"])).verify(
_b64url_decode(sig_b64), payload_b64.encode("utf-8")
)
except (InvalidSignature, ValueError):
raise TokenError("bad signature") from None
claims = json.loads(_b64url_decode(payload_b64))
now = time.time()
if claims.get("iss") != TENANT_ID:
raise TokenError("wrong issuer")
if claims.get("aud") != expected_domain:
raise TokenError("wrong audience")
if int(claims.get("exp", 0)) + CLOCK_SKEW_S < now:
raise TokenError("expired")
if int(claims.get("iat", 0)) - CLOCK_SKEW_S > now:
raise TokenError("issued in the future")
_check_replay(str(claims["jti"]), int(claims["exp"]))
return claims
Using it in a domain endpoint (FastAPI shown; any framework works the same way):
from fastapi import FastAPI, Header, HTTPException
app = FastAPI()
@app.post("/domains/company/get")
def company_get(body: dict, x_waxell_sub_user_token: str | None = Header(None)):
if not x_waxell_sub_user_token:
raise HTTPException(401, "end-user token required")
try:
claims = verify_end_user_token(x_waxell_sub_user_token, expected_domain="company")
except TokenError as exc:
raise HTTPException(401, str(exc))
if "orders:read" not in claims.get("perms", []):
raise HTTPException(403, "not permitted")
return load_company(body["company_id"], acting_user=claims["sub"])
Node.js
Uses only node:crypto and the built-in fetch (Node 18+). No dependencies.
// Verify Waxell end-user tokens (X-Waxell-Sub-User-Token).
import { createPublicKey, verify } from 'node:crypto';
const TENANT_ID = '00000000-0000-0000-0000-000000000000'; // from `wax whoami`
const JWKS_URL =
`https://api.waxell.dev/api/v1/tenants/${TENANT_ID}` +
'/.well-known/waxell-sub-user-jwks.json';
const PREFIX = 'wax_su_v2_';
const JWKS_TTL_MS = 10 * 60 * 1000; // cache keys for up to 10 minutes
const CLOCK_SKEW_S = 30; // tolerance on exp / iat
let keys = new Map();
let keysExpire = 0;
async function getKey(kid) {
if (keys.has(kid) && Date.now() < keysExpire) return keys.get(kid);
// Unknown kid or stale cache: refetch (handles key rotation).
const res = await fetch(JWKS_URL);
if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`);
const jwks = await res.json();
keys = new Map(
(jwks.keys || [])
.filter((k) => k.kty === 'OKP' && k.crv === 'Ed25519')
.map((k) => [
k.kid,
createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: k.x }, format: 'jwk' }),
]),
);
keysExpire = Date.now() + JWKS_TTL_MS;
if (!keys.has(kid)) throw new Error(`unknown signing key ${kid}`);
return keys.get(kid);
}
// Per-process replay cache — use a shared store (e.g. Redis SET NX EX)
// when you run more than one instance.
const seenJti = new Map();
function checkReplay(jti, exp) {
const now = Date.now() / 1000;
for (const [k, until] of seenJti) if (until < now) seenJti.delete(k);
if (seenJti.has(jti)) throw new Error('token replayed');
seenJti.set(jti, exp + CLOCK_SKEW_S);
}
export async function verifyEndUserToken(token, { expectedDomain }) {
if (typeof token !== 'string' || !token.startsWith(PREFIX)) {
throw new Error('not a wax_su_v2 token');
}
const parts = token.slice(PREFIX.length).split('.');
if (parts.length !== 3) throw new Error('malformed token');
const [kid, body, sig] = parts;
const key = await getKey(kid);
// The signature covers the base64url payload STRING, exactly as received.
const ok = verify(null, Buffer.from(body, 'utf8'), key, Buffer.from(sig, 'base64url'));
if (!ok) throw new Error('bad signature');
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
const now = Date.now() / 1000;
if (claims.iss !== TENANT_ID) throw new Error('wrong issuer');
if (claims.aud !== expectedDomain) throw new Error('wrong audience');
if (claims.exp + CLOCK_SKEW_S < now) throw new Error('expired');
if (claims.iat - CLOCK_SKEW_S > now) throw new Error('issued in the future');
checkReplay(claims.jti, claims.exp);
return claims;
}
Using it in an Express handler:
import express from 'express';
import { verifyEndUserToken } from './waxell-verify.mjs';
const app = express();
app.use(express.json());
app.post('/domains/company/get', async (req, res) => {
let claims;
try {
claims = await verifyEndUserToken(req.get('X-Waxell-Sub-User-Token'), {
expectedDomain: 'company',
});
} catch (err) {
return res.status(401).json({ error: err.message });
}
if (!(claims.perms || []).includes('orders:read')) {
return res.status(403).json({ error: 'not permitted' });
}
res.json(await loadCompany(req.body.company_id, { actingUser: claims.sub }));
});
Key rotation
Your JWKS publishes the active key and, during a rotation, the previous
key until its overlap window ends. A token's kid always names the key that
signed it. A verifier that refetches the JWKS when it sees an unknown kid, as
both examples above do, picks up a new key with no configuration change.
Rotate your workspace's key pair from the CLI:
wax sub-user-auth rotate # previous key stays published for 1 hour
wax sub-user-auth rotate --overlap-seconds 7200 # 2-hour overlap
wax sub-user-auth rotate --overlap-seconds 0 # immediate cut-over
Replay protection
Every token carries a unique jti and lives for 5 minutes. Remember each jti
until its exp passes, and reject a token whose jti you've already accepted.
A captured token is then useless to anyone who replays it. In a multi-instance
deployment, keep the seen-jti set in a shared store such as Redis
(SET jti 1 NX EX <seconds until exp>).
Audience binding
aud is the name of the domain the call was made for. A token issued for your
company domain is rejected by an endpoint that expects billing. Always pass
the domain your endpoint serves as expected_domain / expectedDomain.
Related
- Register a Domain: declare
per_user_auth: trueand your endpoints - Signal HTTP Reference: send
_sub_user_identitywhen you fire a signal - Ship a Claude Agent: run an agent as a specific end user