Skip to content

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 saysWhere it comes fromKey set
Session tokenThis 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 serverhttps://api.chaindaddy.io/api/v2/apps/jwks
ReceiptThis wallet bought this item (sku, quantity) in your store (storeId)iap-purchase answers confirmed with it; also GET /api/v2/iap/me/receiptshttps://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 / Ed25519 keys; pick the key by the header's kid (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.
  • typ is a claim. The header's typ is a plain JWT. What the token is, is its typ claim: app-session+v1 or iap-receipt+v1. Check the claim.
  • iss is https://chaindaddy.io.
  • Session tokens. aud is your app id (<developerId>/<name>), and exp is in the future, allowing 30 seconds of clock skew. The other claims, and refusing a replayed jti, are in Session tokens.
  • Receipts. storeId is your store. Credit a player once per orderId. An offline check can't see a refund made since: for anything that matters after the fact, also check online with POST /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 ​

ts
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:

ts
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}`);
}