Aeonis Passport specification
This page defines how an Aeonis Passport reaches a world and how a world checks it. It is written so you can build your own verifier without the Aeonis SDK. The words must, should and may carry their usual RFC 2119 meaning.
- Terms
- How a pass arrives
- Pass format and claims
- Key set (JWKS) and rotation
- Verification steps
- Replay protection
- What is shared, what never is
- Guest fallback
- Automatic avatar looks
- Ownership proof
- Versioning and deprecation
- Test vectors
1. Terms
| Term | Meaning |
|---|---|
| Passport | A visitor’s Aeonis identity: a display name and two avatar colours. Kept by Aeonis; it never holds an email address. |
| World | A website a creator connects to Aeonis, identified by its origin (scheme, host and port, for example https://world.example). |
| Pass | A short-lived signed token (JWT) that Aeonis makes for one visitor and one world when the visitor travels there and agrees to share. |
| Issuer | https://search.aeonis.pro. The only issuer in v1. |
| Audience | The origin of the world the pass was made for. A pass is only valid on that origin. |
| Key set (JWKS) | The issuer’s published public keys, at https://search.aeonis.pro/.well-known/jwks.json. |
| Passport code | The sub claim, aps_ plus 32 characters. Stable for one visitor on one world, different on every other world (pairwise), so worlds can’t link visitors across worlds by it. |
| Guest | What a world gets when there is no valid pass. Worlds must keep working for guests. |
| Look | The visitor drawn to fit a world, returned by the SDK’s adapt() (section 9). |
2. How a pass arrives
Aeonis sends the visitor to the world’s registered address with the pass in the URL fragment: https://world.example/#aeonis_passport=<pass>. Browsers never send the fragment to servers, so the pass doesn’t appear in server logs. A world must remove it from the address bar as soon as it reads it (for example with history.replaceState), and must not log or store the raw pass.
3. Pass format and claims
A pass is a JWS in compact form (header.payload.signature, base64url without padding), signed with Ed25519. The header is {"alg":"EdDSA","typ":"JWT","kid":"…"}. The signature covers the ASCII bytes of header.payload.
| Claim | Type | Meaning |
|---|---|---|
iss | string | Always https://search.aeonis.pro. |
aud | string | The world’s origin, exactly as registered (no path, no trailing slash). |
sub | string | Passport code for this world (see Terms). Required. |
iat, nbf, exp | number | Seconds since 1970 (UTC). nbf = iat − 5, exp = iat + 300: a pass lasts 5 minutes. |
jti | string | A random id, unique per pass. Required. Used for replay protection. |
world | string | The world’s Aeonis id (w_…). |
passport.v | number | 1. |
passport.name | string | Display name, 1–24 characters, chosen by the visitor. Treat it as untrusted text when you display it. |
passport.avatar | object | glb (avatar file URL, served with open CORS), format (glTF-2.0-humanoid), rig (aeonis-ual-1), licence (CC0-1.0) and colors (body, trim as #rrggbb). |
passport.stamps | number | How many worlds the visitor has stamped. A count only, never the list. Left out when Aeonis can’t count them at that moment; the SDK then reports 0. |
passport.return | string | Where to send the visitor home. Only follow it if it starts with https://search.aeonis.pro/. |
Verifiers must ignore claims they don’t know. New optional claims may appear in minor versions.
4. Key set (JWKS) and rotation
GET https://search.aeonis.pro/.well-known/jwks.json returns {"keys":[…]}, served with open CORS. Each key has kty OKP, crv Ed25519, x (the public key, base64url), kid (its RFC 7638 SHA-256 thumbprint), use sig, alg EdDSA, and status:
current: the key that signs new passes.next: published ahead of use. Accept it, so you already have it when signing switches over.retiring: the previous key, kept for an overlap so passes it signed stay valid until they expire.
Verifiers should cache the key set (the HTTP cache lifetime is 5 minutes; a verifier may keep its own copy for up to 30 days and refresh it after 1 hour). When a pass names a kid you don’t have, fetch the key set again, at most once a minute. A verifier may also ship a pinned copy of the keys it knows (SDK 1.2.0 pins the keys published when it shipped). A key set fetched after the pinned copy was made takes precedence over it, so a retired key stops working once a verifier has seen the newer key set.
Rotation policy. A new signing key is published as next before it signs, it then becomes current while the old key stays retiring for at least 1 hour, and then the old key is removed. Its private half is destroyed.
5. Verification steps
Check in this order and stop at the first failure. The reason codes are the ones the SDK and the test vectors use.
- Split into three parts and decode the header and payload as JSON objects. Reject a pass over 4,000 characters. Otherwise
malformed. - The header
algmust beEdDSA. Reject anything else, includingnoneandHS256. Otherwisealg. - Find the key whose
kidmatches the header’skidin your trusted key set. Otherwiseunknown_key. - Verify the Ed25519 signature over
header.payload. Otherwisesignature. Don’t trust any claim before this step passes. issmust behttps://search.aeonis.pro. Otherwiseissuer.audmust equal your own origin. Otherwiseaudience.expmust be later than now − 30 s. Otherwiseexpired.iat(andnbfif present) must be earlier than now + 60 s. Otherwisenot_yet_valid.subandjtimust be strings andpassportan object. Otherwiseclaims.- Optionally, reject a
jtiyou have already accepted (section 6). Otherwisereplayed.
Browsers without Ed25519 in WebCrypto may ask Aeonis instead: POST https://search.aeonis.pro/api/passport/verify with {"token":"…"}. The audience is taken from the request’s Origin header. The answer is {"ok":true,"claims":{…}} or {"ok":false,"reason":"…"}. This is the only step that needs Aeonis to be reachable.
A minimal verifier (Node 22, no dependencies)
import { webcrypto as c } from 'node:crypto';
const b = s => Buffer.from(s, 'base64url');
export async function verifyPass(token, audience, jwks, now = Date.now() / 1000) {
const p = String(token).split('.'); if (p.length !== 3 || token.length > 4000) return 'malformed';
let h, x; try { h = JSON.parse(b(p[0])); x = JSON.parse(b(p[1])); } catch { return 'malformed'; }
if (!h || !x || typeof x !== 'object') return 'malformed';
if (h.alg !== 'EdDSA') return 'alg';
const k = jwks.keys.find(k => k.kid === h.kid && k.kty === 'OKP' && k.crv === 'Ed25519'); if (!k) return 'unknown_key';
const key = await c.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', x: k.x }, 'Ed25519', false, ['verify']);
if (!await c.subtle.verify('Ed25519', key, b(p[2]), Buffer.from(p[0] + '.' + p[1]))) return 'signature';
if (x.iss !== 'https://search.aeonis.pro') return 'issuer';
if (x.aud !== audience) return 'audience';
if (!(x.exp > now - 30)) return 'expired';
if (!(x.iat < now + 60) || (x.nbf != null && !(x.nbf < now + 60))) return 'not_yet_valid';
if (typeof x.sub !== 'string' || typeof x.jti !== 'string' || !x.passport || typeof x.passport !== 'object') return 'claims';
return { ok: true, claims: x };
}
6. Replay protection
A pass is meant to be used once. SDK 1.2.0 records each accepted jti in the world’s localStorage until it expires, so the same pass is refused in every tab of that browser on that site, with no server involved. SDK 1.0.0 and 1.1.0 do this per tab (sessionStorage).
Limits. Browser storage can’t see other browsers or devices, so within its 5 minutes a copied pass could still be accepted once in a different browser. Private windows and cleared storage start empty. If your world has a server, the complete fix is to record each jti there until exp + 60 s and reject repeats. A pass only ever carries what the visitor agreed to share with that one world, and only that world’s origin accepts it.
7. What is shared, and what never is
Only after the visitor agrees, on a consent screen that names the world: display name, avatar colours, stamp count and the passport code for that world. Never shared: email address, sign-in method or account ids, the list of worlds visited, the visitor’s passport codes for other worlds, IP address or location. Aeonis sends no tracking data to worlds. The SDK’s only requests to Aeonis are key-set refreshes, sent without cookies or referrer, and the fallback check in section 5.
8. Guest fallback
When there is no valid pass, the world must keep working and treat the visitor as a guest. The SDK never throws; it returns { guest: true, name: 'Guest', reason, returnUrl } with one of these reasons:
| Reason | When |
|---|---|
no_passport | The visitor came without a pass. |
malformed, alg, unknown_key, signature, issuer, audience, expired, not_yet_valid, claims, replayed | The verification step of the same name failed (section 5). |
unsupported | The browser has no Ed25519 in WebCrypto and Aeonis couldn’t be reached to check it. |
If the SDK file itself can’t load, your own code decides; the snippet in the guide falls back to a guest. Serving your own copy of the SDK file avoids that case.
9. Automatic avatar looks
adapt(visitor) takes no settings. It records how the page draws (it never changes rendering), picks the largest canvas, waits up to 1.5 s for it to draw, and returns one look. The same passport always gives the same seed, colours and shape in every world.
| Detected | Inputs it reads | Returned look |
|---|---|---|
| three.js scene | Scenes and renderer (through three.js’s own developer hook), scene size, rigged-mesh height, most common material type, median roughness and metalness, lights, tone mapping | kind: '3d', glb, height (scene units) and apply(model): recolours with the scene’s own material type and scales so the feet sit at y = 0 |
| Other WebGL or WebGPU canvas | Context type | '3d' with glb and a default height of 1.7 |
| 2D canvas, pixel art | image-rendering, imageSmoothingEnabled, display scale × device pixel ratio, art-pixel size, tile size (8–32 px), exact palette | 'pixel': sprite sheet (rows down, left, right, up × 4 frames) at tile size in the world’s palette, url and draw(ctx, x, y, dir, frame) |
| 2D canvas, smooth | Main colours (k-means, 8) and whether a few flat inks cover the art | 'vector': SVG figure (flat and outlined when the art is flat), height, draw(ctx, x, y, h, face, t) |
| No canvas | — | 'dom': SVG figure and element(), a name tag |
| Anything else, or any error | — | 'core' |
Every look carries identity (name, id, guest, colors, seed, stamps), glyph (an SVG badge) and detected. seed = 32-bit FNV-1a over the UTF-16 code units of lowercase(name) + body + trim (colours as lowercase #rrggbb), modulo 4096. It comes only from shared fields, so it adds no new way to link visitors. Optional overrides: { style, target, tile, scale, palette, height, wait }.
10. Ownership proof
Before review, a creator proves control of the world’s origin with a token from Aeonis (aeonis-verify-…) in one of two ways:
- A file at
https://<origin>/.well-known/aeonis.jsoncontaining{"token":"aeonis-verify-…"}(up to 8 KB), or <meta name="aeonis-verification" content="aeonis-verify-…">in the world page (read from the first 256 KB of the HTML).
Aeonis fetches over https with the user agent AeonisWorldCheck/1.0, follows up to 3 redirects only within the same origin and waits up to 8 seconds. After the proof passes, a person reviews the world before it goes live. Approved worlds are checked about every 25 minutes and are hidden after 3 failed checks in a row until they answer again.
11. Versioning and deprecation
- This spec uses major.minor versions. A minor version only adds optional claims or reasons, so a v1.0 verifier keeps working. A breaking change would be a new major version with a new
passport.v. - Each SDK version has its own fixed address (
/sdk/<version>/aeonis-passport.js) and never changes once published. Fixes ship as new versions. - A published SDK version or claim is kept working for at least 12 months after its replacement is announced on this page.
- Changes to this page are listed below, with dates.
Changes. 1.0 (18 Oct 2026): first published version.
12. Test vectors
vectors.json holds 14 sample passes and one replay check, with the result each must give. They are signed with a test key published inside the file. It is not in the production key set and must never be trusted outside tests. Check them at the fixed time now in the file, against audience https://world.example.
import { verify } from './aeonis-passport-1.2.0.js'; // or your own verifier
const V = await (await fetch('https://search.aeonis.pro/creators/spec/vectors.json')).json();
for (const v of V.vectors) {
const r = await verify(v.token, { audience: V.audience, keys: V.keys, now: V.now });
console.log(v.name, r.ok === v.valid && (v.valid || r.reason === v.reason) ? 'pass' : 'FAIL');
}