Identity
Know who is playing, from a five-minute token signed by uloggd and verified by your server, offline.
A game's client can claim anything about who is playing. Your server needs proof, and gets it as an identity token: a JWT signed by uloggd for one player, one app and one nonce your server issued, valid for five minutes. Your server verifies it offline, against uloggd's public keys. There is no uloggd API to call and no secret to keep.
The flow
- Your server issues a nonce, ties it to the session that will receive the token, and remembers it.
- The game calls
authorize({ scopes: ["identify"] })once, thengetIdentityToken({ nonce }). - The game sends the token to your server.
- Your server verifies it, spends the nonce, and opens its own session for
sub.
Verify again at every session start, and keep your sessions to 24 hours at
most. A player who revokes your game's access stops getting tokens at once,
but the sessions your server already opened are yours to end: the
grant_revoked data event tells you
when.
The token
header { "alg": "ES256", "typ": "uloggd-arcade-id+jwt", "kid": "arc-2026-10" }
payload {
"iss": "https://uloggd.com",
"aud": "YOUR_APP_ID",
"sub": "4f1c2b7e-...", the player's id in your game
"nickname": "Ada",
"scope": "identify",
"iid": "0b7e9a31-...", the play, as instance_id in the launch URL
"mode": "live", or "test"
"nonce": "...", the one your server issued
"iat": 1791860000,
"exp": 1791860300, iat + 300
"jti": "9d2e5f60-..."
}subis a uuid unique to this player in this game; another game sees a different one for the same person. It stays the same across plays, and changes only when the player chooses Forget this game.nicknameis the one the player chose for your game. It can change, and thenickname_changeddata event tells you.modeistestwhen you play your own release in test mode, or your development URL. Such a token has the sameissandaudas any other, and no claim of its own. Reviewers play inlive, with a freshsuband nickname for each review.- Never in it: the email, uloggd's own ids, the birth date, the age, the IP address.
Verify it in Node
import {
verifyIdentityToken,
ArcadeTokenError,
} from "@uloggd/arcade-sdk/server";
app.post("/session", express.json(), async (req, res) => {
const nonce = takeNonce(req); // the nonce issued to this session; usable once
try {
const player = await verifyIdentityToken(req.body.token, {
appId: process.env.ULOGGD_APP_ID,
nonce,
});
// { subject, nickname, scopes, instanceId, mode, id, issuedAt, expiresAt }
if (!(await rememberOnce(player.id))) return res.status(409).end();
res.json({ session: await openSession(player.subject, player.nickname) });
} catch (error) {
if (error instanceof ArcadeTokenError)
return res.status(401).json({ error: error.code });
throw error;
}
});On a CommonJS server, require("@uloggd/arcade-sdk/server") gives the same
functions.
| Option | |
|---|---|
appId | Your app id. Required. |
nonce | The nonce your server issued. Required. |
allowTestMode | false refuses tokens from test mode (mode: "test"). They are accepted by default. |
allowMock | Accept the mock's tokens (iss: "mock"). Local development only. |
clockSkewSeconds | The clock skew to allow; 60 by default. |
fetch | A fetch of your own, for the key set. |
jwksUrl, issuer | Only https://uloggd.com, or a loopback origin in tests. Leave them out. |
A refusal is an ArcadeTokenError with a code: malformed,
unsupported_header, unknown_key, bad_signature, wrong_type,
wrong_issuer, wrong_audience, expired, not_yet_valid, too_old,
nonce_mismatch, mock_refused, test_refused or jwks_unavailable.
The verifier keeps the keys for 5 minutes at most, whatever the response
headers say, and refetches them on an unknown kid at most once a minute.
Without current keys, a failed fetch makes it wait 10 seconds before asking
again, refusing with jwks_unavailable meanwhile.
Verify it in any language
Any JOSE library that lets you pin the algorithm will do, as long as every rule below is checked:
- Shape. At most 8192 characters, in three base64url segments separated by dots. Decode the first two as JSON objects.
- Header. Exactly
alg,typandkid, and nothing else.algisES256: pin it in your code, never let the token choose.typisuloggd-arcade-id+jwt, andkidis a string. Refusejku,jwk,x5u,x5c,critand any other member. - Key. Find
kidin the key set athttps://uloggd.com/api/v1/arcade/jwks. An unknownkidmay trigger one refetch, at most once a minute. - The set. Accept only a
200answer, following no redirect, of at most 64 KiB, that is JSON with akeysarray. Read only its first 20 entries, and of those only the ones withktyEC,crvP-256,xandyof 43 base64url characters,algabsent orES256, anduseabsent orsig. For eachkidthe first valid entry counts. After a failed fetch, wait 10 seconds before fetching again. - Signature. ECDSA over P-256 with SHA-256, over the bytes of the first
two segments as received, joined by a dot. The signature is the raw 64
bytes of
rthens; refuse a DER-encoded one, and convert if your library wants DER. issis exactlyhttps://uloggd.com.audis exactly your app id.- Time.
iatandexpare integers, andexpcomes afteriat. Allowing 60 seconds of clock skew:exphas not passed,iatis not in the future, andiatis at most 300 seconds old. - Members.
sub,jti,nicknameandiidare strings. nonceis the one your server issued to this session, not used before. Mark it used.scopeincludesidentify.modeisliveortest. Decide where your own test plays may sign in, and refusetesteverywhere else.- Replay. Remember each
jtiand each nonce for longer than a token lives, and refuse a repeat. Deduplicate byjtiand nonce, never by the token's text: ECDSA signatures are malleable, so the same claims can arrive with a different signature.
Verifying in other languages publishes a
complete verifier in Python, which checks every one of these rules and is
verified against the Node SDK, with the rules for data event tokens, the
refusal codes and what to check when you use a JWT library. Verifiers in Go
and C# are written in uloggd's repository (packages/arcade-sdk/examples, not
part of the npm package) but not yet verified; the page publishes each one
once it passes.
The key set
GET https://uloggd.com/api/v1/arcade/jwks answers a public JSON Web Key Set,
with Cache-Control: public, max-age=300, stale-while-revalidate=60. It lists
the key that signs today and any key that is about to sign or on its way out.
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "...",
"y": "...",
"kid": "arc-2026-10",
"alg": "ES256",
"use": "sig"
}
]
}- Keep it cached for 10 minutes at most. The longer a verifier holds the keys, the longer a withdrawn key keeps working for it.
- Fetch it from
https://uloggd.comonly. Never from a*.squareweb.appaddress: those names belong to uloggd's hosting provider, and a name released there can be claimed by another customer serving keys of its own. - Pinning the keys below in your verifier, instead of fetching them, is supported, as long as you follow the rotation calendar (see Pinning the keys).
Rotation
Keys are named arc-YYYY-MM and rotate every 6 months:
- The new key joins the set, for verifying only, and is listed on this page at the same moment, with the date it starts signing. Pinned verifiers need those 48 hours.
- 48 hours later it starts signing.
- 24 hours after that, the old key leaves the set.
A verifier that refetches the set at least every 10 minutes never notices. A pinned verifier must add the new key within those 48 hours.
If a key is ever compromised, it leaves the set at once, and every Arcade developer gets an email.
Current public keys
kid | Signing since | Leaves the set |
|---|---|---|
arc-2026-10 | 2026-10-14 | no date yet |
The next rotation is planned for April 2027, with the key arc-2027-04. The
exact dates are posted here at least 7 days ahead.
The key that signs today, as the key set publishes it:
{
"kty": "EC",
"crv": "P-256",
"x": "43itCWOfjgyC_mP-7YI8Vg3wmHMC2bkPKlod1D1qYcA",
"y": "jey41Kgw1_imPbgSuhlhTHutWiZeY9jnoHf45jKd_UA",
"kid": "arc-2026-10",
"alg": "ES256",
"use": "sig"
}The whole set, with any key about to sign or on its way out, is always at
GET https://uloggd.com/api/v1/arcade/jwks.
Pinning the keys
When the set is fetched, any answer that is not JSON, such as a challenge page
or an error, makes the SDK refuse with jwks_unavailable. A verifier that
would rather not depend on that fetch can pin the keys above: pass
verifyIdentityToken a fetch option that returns the published set as a
Response with status 200 and cache-control: max-age=300.
import { verifyIdentityToken } from "@uloggd/arcade-sdk/server";
// The set as this page publishes it. Add the new key as soon as it is
// listed under "Current public keys".
const PINNED_JWKS = JSON.stringify({
keys: [
{
kty: "EC",
crv: "P-256",
x: "43itCWOfjgyC_mP-7YI8Vg3wmHMC2bkPKlod1D1qYcA",
y: "jey41Kgw1_imPbgSuhlhTHutWiZeY9jnoHf45jKd_UA",
kid: "arc-2026-10",
alg: "ES256",
use: "sig",
},
],
});
const pinned = async () =>
new Response(PINNED_JWKS, {
status: 200,
headers: { "cache-control": "max-age=300" },
});
const player = await verifyIdentityToken(token, {
appId: process.env.ULOGGD_APP_ID,
nonce,
fetch: pinned,
});verifyEventToken takes the same fetch option. Follow the
rotation: add the new key within
the 48 hours between its listing here and its first signature, and drop the
old one once it has left the set.