← All docs Games for Group Β· Audit Β· 2026-08-02

Security, scale & abuse audit

A full review of the backend, the website, the app and the LAN phone-server, run before launch and before the app is put in front of a crowd. Twelve reviewers worked in parallel, then a second round tried to disprove everything the first round claimed.

πŸ”’ 37 raised β†’ 16 held up β†’ 15 fixed. Nothing found was being exploited, and nothing found was a leak of anyone's data. The most serious issue was not a break-in at all: the app would have fallen over on the day a video went viral. That is fixed. What is left is at the bottom β€” one thing to do before you promote the app, and the rest deliberately declined.
⏳ This page is temporary It exists to track what is left. Once that is closed, delete it β€” the reasoning worth keeping has already been copied into decisions and learnings, which are the permanent record.

The big one: what would have happened on a viral day

You asked for the app to survive a sudden rush, not just to be secure. Three problems would only ever have shown up under exactly that pressure β€” which is the worst possible time to discover them, and the reason none of our testing found them.

Was: games would refuse to start

Everyone fighting over one line in the notebook

Every time anyone started a game, the app wrote the play count for that game into a single place. One writer at a time is fine. A thousand people starting Tambola in the same minute all queue for the same line, and the ones who lose the race get an error instead of a game.

Now: the tally is split across 16 separate lines per game, picked at random. Sixteen times less queueing, and it never gets worse as more people arrive.

Was: the whole site slows itself down

Shouting an update at every visitor

The home page's "Trending" row watched those live counts. So every game started anywhere in the world made every person on the home page recalculate the list. Ten thousand visitors plus a steady trickle of games is a flood the backend creates for itself β€” the classic way a busy launch takes itself down.

Now: the list is worked out once every 10 minutes and saved. Visitors read that one saved answer. Traffic no longer causes work.

Was: cleanup silently stops forever

The bin lorry that gets stuck and never returns

A daily job deletes finished rooms so codes can be reused. It limited how many rooms it looked at, but not how much it deleted. A batch of unusually busy rooms could exceed the allowed work, get cancelled, and be retried identically β€” forever. Old rooms would pile up and eventually you'd run out of room codes.

Now: it counts what it deletes, stops at a safe point, and picks up exactly where it left off.

πŸ”„ Since this audit The first two cards describe things that no longer exist. The "Trending" row and its play counter were deleted the same day β€” no screen showed them. Online play is counted again since 2026-09-14, in a new tally table that follows the same rule (split across several rows per game, summed on read, read only from the Convex dashboard); see the backend page. The rooms reaper now closes a room after thirty minutes of silence, with the same deletion budget. The site's security headers are written by scripts/build-site.mjs into the folder Cloudflare serves.

Everything fixed today

What was wrongWhy it matteredWhere
A player leaving mid-game could freeze it permanently Each game must say what happens when someone leaves (some can rebalance, a two-player duel cannot). One older "leave" path skipped that rule and just removed the person β€” leaving the game waiting forever for someone who was gone. No attacker needed: tapping Leave at the same moment the host taps Start was enough. convex/rooms.ts
A dead contact form was still accepting writes The contact page opens your email app and always has. But an old, forgotten submission endpoint was still live and open to the whole internet, and its only anti-spam check could be defeated by changing one word. It had no callers. We deleted it rather than hardening it β€” the right fix for a door nobody uses. convex/contact.ts (removed)
Names could be faked to look identical Room titles were cleaned of invisible characters; player names were not. So "Karim" and "Karim⁠" (with a hidden character) counted as different names but looked the same in the roster β€” and text-direction characters could scramble the layout for everyone. Now names get the same cleaning, and the duplicate check compares them properly. convex/lib/roomTitle.ts
Unlimited-length session IDs were stored The ID a guest's browser uses to hold their seat was written down at whatever length it arrived. A huge one would bloat every read of that room and could jam the cleanup job. Now bounded before it is stored. convex/lib/access.ts
Shared links previewed as bare URLs Your whole growth plan is people pasting links. Every one of them β€” the social post, and every room invite in a group chat β€” unfurled as plain text with no picture and no title. Room links had no page title at all. Now there is a branded preview card, each game page previews with its own poster, and room links say "join the room". site + +html.tsx
Blank screen while fonts downloaded The web app refused to draw anything until several megabytes of fonts arrived β€” so a stranger tapping your link on mobile data stared at nothing. Now the web draws immediately and the fonts swap in. (The phone app still waits, correctly: its splash screen is already covering that moment.) _layout.tsx
No security headers at all Added the browser-level protections: your pages can no longer be secretly framed inside another site (a way to trick taps), the browser refuses to guess file types, HTTPS is enforced, and the page is only allowed to talk to our own backend β€” so injected code would have nowhere to send anything. build-site.mjs
Developer-only screens were publicly reachable Internal galleries (/fx, /catalog, /gate, /caller-preview, /lan-spike) plus an auto-generated index listing every screen were served on the live domain. Their pages are now deleted while the site is assembled, and the build fails if one survives. They still work while developing. build-site.mjs
The room code was the only thing protecting a room Anyone holding a code could take a seat, and at four characters there are only about 810,000 of them β€” few enough for a script to sweep and walk into whichever rooms happened to be open. The answer we shipped was not a longer code but a door: every room is private, and holding the code only earns the right to knock. Nobody is seated until the host taps Approve, so a swept code now buys a request somebody declines. Codes stayed at four characters. Ships with your next app build. convex/rooms.ts Β· packages/engine/src/room.ts
LAN: the host's identity was reused The secret proving "I am the host, let me start or end the game" was the same ID the phone sends in the clear whenever it joins someone else's LAN game. So a value already broadcast on past Wi-Fi networks doubled as the host's password. Now a fresh random one is minted for each hosting session, and it is never stored. Next app build. lan/hostSession.ts
LAN: one connection could hold several seats A single browser could claim seat after seat, and hanging up only released the last one β€” the rest sat in the player list forever. Both release paths now share one piece of code, so they cannot drift apart again. Next app build. lan/hostSession.ts
Broken install prompt "Add to home screen" offered a nameless app with a white loading screen against a dark game. Fixed name, icons and colours. site.webmanifest
🧹 Public rooms: removed, as you decided The public rooms directory is gone from v1. That was your call for product reasons, and it independently closed a security surface: it published a live list of joinable room codes, plus stranger-visible room titles and host names. Removing it means the only way into a room is a code someone gave you. The text-cleaning it introduced was kept and reused for player names.

What is still open β€” and why

πŸšͺ The room-code worry was answered by a door, not by longer codes The original finding assumed whoever holds a code takes a seat. That is no longer true: every room is private and the host approves each arrival. Codes stayed at 4 characters deliberately (~810,000 combinations) β€” a swept code produces a knock somebody declines, not entry. The reasoning is in decisions under "Every room is private β€” you knock, the host opens the door".

What remains is one deliberate size question, one real gap the knock gate does not close, and one limit we chose to live with. (The two LAN items that used to sit here shipped: a fresh host identity per hosting session, and one seat per connection β€” see the fixed table above.)

ItemRisk todayWhy it waits
A swept code still reveals that a room exists
and its title, host name and player count
Low, and unchanged by the knock gate. The lookup a join screen does needs no sign-in, so a script sweeping all ~810,000 codes learns which rooms are live right now and reads each one's title, host name and how many people are in it. It cannot get in β€” that still takes a host tap β€” and it cannot see any gameplay. But room metadata is real information, and nothing limits how fast the sweep can go. Rate limiting is the fix, not a longer code. Cap unauthenticated room lookups and knocks per device and per address before the app is promoted; a sweep that has to crawl learns nothing useful. Deferred only because there are no real users yet.
A turned-away guest can knock again from a new browser
the same session cannot
Low, by design. Keep out refuses the same session on that room (2026-09-14), and the server enforces it at the knock. A guest is an anonymous device, so a private window is a new session and knocks again. Every knock still needs a host tap, so the worst case is a repeated row the host turns away. Guest accounts would make a ban keepable. Until then, the knock rate limit below is what stops one person filling the host's door. See decisions.
Dev screens still inside the download
measured: 211 KB of 3.6 MB
None. They are unreachable in public β€” this is file size only. Measured and deliberately declined. Removing them shrinks the download by 6% (~60 KB compressed), which does not change how the app feels. The only way to get it while keeping the galleries usable in development is to restructure five route files β€” real risk, no user-visible gain. Revisit if the bundle is ever attacked properly, where this is a footnote next to the other 3.4 MB.
βœ… Before you promote the app Rate-limit the two unauthenticated doors. Room lookup by code, and knocking. Neither needs a sign-in β€” by design, since a guest is just someone with a link β€” so neither has anything but our own goodwill limiting how many attempts a script makes per second. Everything else on this page is either fixed or deliberately declined; this is the one item that has to land before strangers arrive.
πŸ›‘οΈ One thing to know about the browser rules The strictest possible browser protection would also block the game from running β€” the app's own code legitimately needs the abilities that rule would forbid. So that one setting is deliberately relaxed while all the others are switched on. Tightening it depends on a change in Expo (the framework), not in our code. It is written down in scripts/build-site.mjs so nobody "fixes" it by accident and breaks play.

How this was checked

StageWhat happened
Six specialistsSign-in & permissions Β· data handling Β· scale under load Β· the LAN phone-server Β· the website Β· abuse & spam. Each read the real code, not a summary.
Six challengersEach finding was handed to a reviewer told to destroy it β€” open the file, prove the claim wrong, or prove the risk was overstated. 21 of 37 died here, including several that sounded alarming.
Then verifiedEvery fix was type-checked and tested, the site was rebuilt and its routes checked in a browser, and the backend jobs were run once against production to confirm they behave.

Judged against the real world, not a worst case: friends and family playing together, a host who invites people, and LAN games played by people in the same room. Findings that only made sense against a determined stranger attacking a bank were marked down accordingly.

Games for Group Β· Security & scale audit Β· 2026-08-02 Β· Temporary β€” delete once the open items are closed. Permanent reasoning lives in decisions and learnings.