Dual identity trust
1. Foundational mental model
Section titled “1. Foundational mental model”A player has up to two identities:
- Session identity. A random UUID in the
session_tokencookie. 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.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”3. Concrete implementation
Section titled “3. Concrete implementation”The cookie
Section titled “The cookie”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.
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 handshake
Section titled “The handshake”The SvelteKit side verifies the JWT by asking Convex, then forwards:
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).
The map on Bun
Section titled “The map on Bun”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.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Sliding TTL
Section titled “Sliding TTL”Every upsert sets storedAt = now; peekSessionFingerprint does not. An entry is readable at time when
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.
Size bound
Section titled “Size bound”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 be the number of distinct session tokens upserted in the trailing 24 hours. Then
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.
Why the link is safe to trust
Section titled “Why the link is safe to trust”The binding needs three facts to hold:
- The
sessionTokenis the caller’s own cookie. SvelteKit reads it fromcookies.get("session_token"), never from the body. - The
convexUserIdbelongs to the caller. SvelteKit gets it from Convex with the caller’s JWT. - Only SvelteKit can write to the map. Bun requires
x-backend-secretto equalBACKEND_CONVEX_SHARED_SECRETorBACKEND_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”| Failure | Effect | Detection |
|---|---|---|
| Server restart or redeploy | Map 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 lost | Client captureMessage("Solo submit returned no XP token for signed-in player") to Sentry |
| Bun unreachable from SvelteKit | fetch in stageTrustedSessionConvexUser is not wrapped in try, so /api/session/trust throws a 500; the client swallows it | SvelteKit error logs |
Missing BACKEND_CONVEX_SHARED_SECRET or Convex URL on Vercel | stageTrustedSessionConvexUser returns false, endpoint answers 401 | Every signed-in submit logs the Sentry warning above |
| Sign-out without network | DELETE 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 accountId | peekTrustForRoomMember falls back to the member id. Anonymous seats are not in the trust map, so no XP token is issued. | Expected for guests |
Secret rotation
Section titled “Secret rotation”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.
Other edges
Section titled “Other edges”- Token readable by page scripts.
httpOnlydoes not stop XSS from readingsession_token, since/api/sessionreturns 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.
backendSecretOkreturns 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-trustruns Arcjet inwithMiddleware. Indry_runnothing is blocked. Inlivemode, all staging calls come from Vercel’s IPs and share Arcjet’s per-IP token bucket (capacity 30, refill 10 per 60 s).
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why this one |
|---|---|---|
| SvelteKit verifies the JWT and tells Bun | Bun verifies Convex JWTs itself | Bun would need Convex Auth’s JWKS handling and issuer config. SvelteKit already has a Convex client. |
| In-memory map with sliding 24 h TTL | Persist links in stats.db or Convex | Links 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 submit | Stage once at sign-in | Survives 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 _PREV | Separate secrets per hop | Fewer 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.