Skip to main content

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:

  1. The domain opts in. The domain contract sets per_user_auth: true at the top level:

    # company.yaml
    name: company
    per_user_auth: true
    auth: { … }
    endpoint: { … }
    actions: [ … ]
  2. The run carries an end user. The signal that started the run included _sub_user_identity in 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​

HeaderContentsUse it for
X-Waxell-Sub-User-IdYour user's id, plain textLogging and tracing only
X-Waxell-Sub-User-EmailYour user's email, plain text (sent when known)Logging and tracing only
X-Waxell-Sub-User-Tokenwax_su_v2_<kid>.<payload>.<signature>Authorization. This is the authoritative, signed identity.
Authorize on the token, never the plain headers

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)>
PartMeaning
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​

ClaimTypeMeaning
issstring (UUID)Your Waxell workspace (tenant) id. Check it equals your tenant id.
substringYour user's id: the sub_user_id you sent.
emailstring or nullYour user's email, if provided.
namestring or nullDisplay name, if provided.
tenant_idstring (UUID)Same value as iss.
audstringThe domain name the call is for (e.g. "company"). Check it equals the domain your endpoint serves.
iatinteger (Unix seconds)Issued at.
expinteger (Unix seconds)Expires at: iat + 300 (5 minutes).
jtistringUnique token id, for replay protection.
rolesstring[]Roles you sent for the user.
permsstring[]Permissions you sent for the user.
groupsstring[]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​

  1. The token starts with wax_su_v2_ and splits into exactly three .-separated parts.
  2. The kid is in your JWKS. If it isn't, refetch the JWKS once before rejecting (see Key rotation).
  3. The Ed25519 signature over the payload string verifies against that key.
  4. iss equals your tenant id.
  5. aud equals the domain name this endpoint serves.
  6. exp is in the future. Allow a small clock skew; 30 seconds is plenty.
  7. jti hasn'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.