Skip to content

A player has up to two identities:

  • Session identity. A random UUID in the session_token cookie. Every visitor gets one, signed in or not. The Bun game server sees it on every HTTP request and WebSocket upgrade, and uses it to sign solo ledgers, key rate limits, and count unique players per country.
  • Account identity. A Convex Auth user id (Id<"users">). Only signed-in players have one. Convex sees it through the JWT; the Bun server has no Convex client and cannot verify a JWT itself.

XP must go to the account, but game results arrive at Bun keyed by session. The trust link is a server-side map from session_token to convexUserId, written only after SvelteKit has verified the player’s Convex JWT. The browser never tells Bun who it is.

ensureAnonymousSessionCookies issues the token when it is missing. GET /api/session calls it so prerendered pages (which skip the handle hook) can still get one.

apps/web/src/lib/server/anonymous-session-cookies.ts
function sessionTokenCookieOptions(): SessionTokenCookieOptions {
const options: SessionTokenCookieOptions = {
httpOnly: true,
secure: readNodeEnv() === "production",
sameSite: "strict",
maxAge: SESSION_TOKEN_MAX_AGE_SECONDS,
path: "/",
};
if (readNodeEnv() === "production") {
options.domain = ".flags.games";
}
return options;
}

SESSION_TOKEN_MAX_AGE_SECONDS is 60 * 60 * 24 * 365. The .flags.games domain lets api.flags.games read the same cookie. The token value is crypto.randomUUID().

The cookie is httpOnly, but GET /api/session returns the token in JSON ({ token }) and +layout.server.ts passes it to page data, because the client needs it to sign solo ledgers. JavaScript on the page can therefore read it.

The SvelteKit side verifies the JWT by asking Convex, then forwards:

apps/web/src/lib/server/session-trust-forward.ts
const client = new ConvexHttpClient(convexUrl);
client.setAuth(convexJwt);
let convexUserId: string | null;
try {
convexUserId = await client.query(api.players.getCurrentUserId, {});
} catch {
return false;
}
if (!convexUserId) {
return false;
}
const response = await fetch(`${resolveFlagsApiBaseUrl()}/session-trust`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-backend-secret": backendSecret,
},
body: JSON.stringify({
sessionToken,
convexUserId,
}),
});

resolveFlagsApiBaseUrl() ends in /api, so the request lands on the Bun route /api/session-trust, wrapped in withMiddleware (Arcjet, which defaults to ARCJET_MODE=dry_run, plus request validation).

apps/server/src/lib/country-stats/session-fingerprint-store.ts
const SESSION_FINGERPRINT_TTL_MS = 24 * 60 * 60 * 1000;
const SESSION_FINGERPRINT_MAX_ENTRIES = 1000;
function upsertSessionFingerprintEntry(
sessionUserId: string,
patch: Partial<Pick<SessionFingerprintEntry, "fingerprint" | "convexUserId">>
): void {
const now = Date.now();
if (sessionFingerprints.size >= SESSION_FINGERPRINT_MAX_ENTRIES) {
pruneExpiredFingerprints(now);
}
const existing = sessionFingerprints.get(sessionUserId);
sessionFingerprints.set(sessionUserId, {
fingerprint: patch.fingerprint ?? existing?.fingerprint,
convexUserId: patch.convexUserId?.trim() || existing?.convexUserId,
storedAt: now,
});
}

The same map also stages the device fingerprint used for Convex country-stat trust scoring. Four writers touch it: postSessionTrust, storeSessionFingerprint after a valid solo replay, the SESSION_TRUST_CONTEXT WebSocket message (fingerprint only, keyed by the socket’s accountId), and deleteSessionTrust.

Multiplayer does not peek the map with the seat id. peekTrustForRoomMember loads the seat’s accountId (the session_token from join or create) and peeks that key. processGameResultsAndIssueXP and recordGameStatsForCountry both use it, so a signed-in player gets claimUserId and the country-stat forward gets convexUserId and fingerprint.

Every upsert sets storedAt = now; peekSessionFingerprint does not. An entry is readable at time tt when

t−tlast upsert≤24 ht - t_{\text{last upsert}} \le 24\,\text{h}

Signed-in solo players re-stage before each submit, and each accepted replay with a fingerprint upserts again, so an active player’s link does not expire. A signed-in tab that never submits solo and never opens a socket lets its link lapse after 24 hours.

Pruning runs only when the map already holds 1000 entries, and it removes only expired entries. After pruning, every survivor was upserted in the last 24 hours, and the new entry is added whether or not anything was removed. Let N24N_{24} be the number of distinct session tokens upserted in the trailing 24 hours. Then

∣map∣≤max⁡ ⁣(1000,  N24+1)|\text{map}| \le \max\!\big(1000,\; N_{24} + 1\big)

Below 1000 distinct sessions per day the map holds at most about 1000 entries. Above that, it grows with daily traffic and nothing evicts live entries. Memory per entry is small (a UUID key, an optional fingerprint object, an optional id), so this is a growth characteristic to know about rather than an outage risk at current scale.

The binding needs three facts to hold:

  1. The sessionToken is the caller’s own cookie. SvelteKit reads it from cookies.get("session_token"), never from the body.
  2. The convexUserId belongs to the caller. SvelteKit gets it from Convex with the caller’s JWT.
  3. Only SvelteKit can write to the map. Bun requires x-backend-secret to equal BACKEND_CONVEX_SHARED_SECRET or BACKEND_CONVEX_SHARED_SECRET_PREV.

The XP token then carries claimUserId under an HMAC-SHA256 signature with the same secret, and Convex checks it against the claimer’s identity.

5. Threat model, failure modes & edge cases

Section titled “5. Threat model, failure modes & edge cases”
FailureEffectDetection
Server restart or redeployMap empty. Solo recovers on the next submit because the client re-stages first.None on the server
Trust staging failed (Convex query error, Bun unreachable)Solo submit succeeds, xpToken is null, XP for that game is lostClient captureMessage("Solo submit returned no XP token for signed-in player") to Sentry
Bun unreachable from SvelteKitfetch in stageTrustedSessionConvexUser is not wrapped in try, so /api/session/trust throws a 500; the client swallows itSvelteKit error logs
Missing BACKEND_CONVEX_SHARED_SECRET or Convex URL on VercelstageTrustedSessionConvexUser returns false, endpoint answers 401Every signed-in submit logs the Sentry warning above
Sign-out without networkDELETE fails silently and the link stays until TTL. Sign-out does not rotate session_token. If a different account signs in on that browser, staging overwrites the id. If the next person plays signed out, tokens still name the previous account; Convex rejects them for anyone else.None
Seat has no accountIdpeekTrustForRoomMember falls back to the member id. Anonymous seats are not in the trust map, so no XP token is issued.Expected for guests

One secret, BACKEND_CONVEX_SHARED_SECRET, covers four jobs: the SvelteKit-to-Bun x-backend-secret, the Bun-to-Convex x-convex-secret, XP token HMAC signing, and the globe actor hash. Bun and Convex accept _PREV on inbound checks, and Convex’s sharedBackendSecrets tries both when verifying tokens. SvelteKit sends only the current value.

A safe order: set the new value as current and the old one as _PREV on Bun and Convex, then update Vercel, then drop _PREV after XP tokens (30-minute expiry) and in-flight requests have drained. Two side effects remain. computeGlobeActorHash uses only the current secret, so globe throttling treats every player as a new actor right after rotation. The trust map itself is unaffected because it stores no secret-derived values.

  • Token readable by page scripts. httpOnly does not stop XSS from reading session_token, since /api/session returns it. An attacker with XSS could submit results as that session, but could not stage a trust link without the victim’s Convex JWT, which the same XSS could also use.
  • Length check before compare. backendSecretOk returns early when lengths differ, which reveals the secret length through timing. The secret is high-entropy, so this has little practical value to an attacker.
  • Shared Vercel egress. /api/session-trust runs Arcjet in withMiddleware. In dry_run nothing is blocked. In live mode, all staging calls come from Vercel’s IPs and share Arcjet’s per-IP token bucket (capacity 30, refill 10 per 60 s).
ChoiceAlternativeWhy this one
SvelteKit verifies the JWT and tells BunBun verifies Convex JWTs itselfBun would need Convex Auth’s JWKS handling and issuer config. SvelteKit already has a Convex client.
In-memory map with sliding 24 h TTLPersist links in stats.db or ConvexLinks are cheap to re-create and the client re-stages before each submit. Persistence would add a table that holds session-to-account pairs at rest.
Staging before every signed-in submitStage once at sign-inSurvives server restarts and TTL expiry without extra state on the client. The cost is one SvelteKit round trip, one Convex query, and one Bun request per game.
One shared secret with _PREVSeparate secrets per hopFewer values to manage. A leak of the one secret compromises all four uses at once.

Non-goals: letting the client assert its account id to Bun, keeping links across Bun restarts, and linking one session token to more than one account at a time.