Verifying tokens
Two things Chain Daddy signs reach your own server from an app, and both are claims to check before you act on them:
| What it says | Where it comes from | Key set | |
|---|---|---|---|
| Session token | This wallet (sub) is signed in and using your app (aud) on a token page, now (exp, 5 minutes) | Your app sends the session-token action (needs net:connect) and passes the token to your server | https://api.chaindaddy.io/api/v2/apps/jwks |
| Receipt | This wallet bought this item (sku, quantity) in your store (storeId) | iap-purchase answers confirmed with it; also GET /api/v2/iap/me/receipts | https://api.chaindaddy.io/api/v2/iap/jwks |
Both are compact JWS signed with Ed25519 (alg: EdDSA), checked offline against the platform's published keys. Never trust either because a client sent it.
The rules
- Signature. EdDSA, against the right key set. Session tokens and receipts are signed with different keys, so a receipt never verifies as a session token, or the other way round. Each key set is a JWKS of
OKP/Ed25519keys; pick the key by the header'skid(the first 16 hex characters of the key's SHA-256). The set lists the current key first and keeps the previous one after a rotation. typis a claim. The header'stypis a plainJWT. What the token is, is itstypclaim:app-session+v1oriap-receipt+v1. Check the claim.issishttps://chaindaddy.io.- Session tokens.
audis your app id (<developerId>/<name>), andexpis in the future, allowing 30 seconds of clock skew. The other claims, and refusing a replayedjti, are in Session tokens. - Receipts.
storeIdis your store. Credit a player once perorderId. An offline check can't see a refund made since: for anything that matters after the fact, also check online withPOST /api/v2/iap/receipts/verify(Verifying receipts).
A score attestation (typ: app-score+v1) is signed with the session key set too, and the typ check is what keeps one from passing as a session token.
In TypeScript, with jose
import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from 'jose';
const API = 'https://api.chaindaddy.io'; // the platform API: where the public keys are
const ISSUER = 'https://chaindaddy.io'; // the `iss` of everything Chain Daddy signs
// Two key sets: session tokens and receipts are signed with different keys. Each is cached, and refetched on an
// unknown key id, so a key rotation needs nothing from you.
const sessionKeys = createRemoteJWKSet(new URL('/api/v2/apps/jwks', API));
const receiptKeys = createRemoteJWKSet(new URL('/api/v2/iap/jwks', API));
/** A session token (the `session-token` action) for your app: `sub` is the signed-in visitor's wallet. */
export async function verifySession(token: string, appId: string, keys: JWTVerifyGetKey = sessionKeys, now?: Date): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, keys, { algorithms: ['EdDSA'], issuer: ISSUER, audience: appId, clockTolerance: 30, currentDate: now });
// what a token IS lives in its claims: the header's `typ` is a plain "JWT"
if (payload.typ !== 'app-session+v1') throw new Error('not an app session token');
if (!payload.exp) throw new Error('the session token has no expiry');
return payload;
}
/** A receipt from a confirmed purchase in your store. Credit a player once per `orderId`. */
export async function verifyReceipt(receipt: string, storeId: string, keys: JWTVerifyGetKey = receiptKeys, now?: Date): Promise<JWTPayload> {
const { payload } = await jwtVerify(receipt, keys, { algorithms: ['EdDSA'], issuer: ISSUER, currentDate: now });
if (payload.typ !== 'iap-receipt+v1') throw new Error('not an in-app purchase receipt');
if (payload.storeId !== storeId) throw new Error('the receipt is for another store');
return payload;
}sub of a verified session is the visitor's wallet (lowercase 0x… on EVM, base58 on Solana): sign them in to your server with it, with no wallet prompt.
In another language
The rules above are the whole check; any JOSE library with EdDSA does it. The FLUX GP kit's game server does it in Go: server/cdapi in flux-gp-kit.
Test vectors
tokens.json holds two published key sets (jwks.session, jwks.receipts), a fixed time now (Unix seconds), an appId, a storeId, and 15 tokens marked valid or not: expiry and clock skew, another app, another issuer, typ only in the header, no expiry, a forgery under the published key id, a receipt offered as a session token and the reverse, and another store. Their key ids are readable names rather than hashes.
Run your check over every case at now: it passes when it accepts exactly the valid ones. With the code above:
import { readFileSync } from 'node:fs';
import { createLocalJWKSet } from 'jose';
import { verifyReceipt, verifySession } from './verify-tokens';
const v = JSON.parse(readFileSync('tokens.json', 'utf8'));
const now = new Date(v.now * 1000);
const sessionKeys = createLocalJWKSet(v.jwks.session);
const receiptKeys = createLocalJWKSet(v.jwks.receipts);
for (const c of v.cases) {
const accepted = await (c.kind === 'session'
? verifySession(c.token, v.appId, sessionKeys, now)
: verifyReceipt(c.token, v.storeId, receiptKeys, now)
).then(() => true, () => false);
if (accepted !== c.valid) throw new Error(`wrong answer for: ${c.name}`);
}