CogniPrep is a practice platform for the game-based assessments employers use for screening. For about three days it let a signed-out visitor play one free game per provider, and that small feature produced the most interesting access-control decision in the codebase.
The problem was narrow. Our authored practice content is served by an authenticated route on purpose: the content is the product, and routing it through a route that requires an account means pulling the library costs an account and shows up in logs attributable to one. Guest play made that check wrong for exactly one case. A visitor with no account was now deliberately allowed to play, and a game that cannot load its content is just a spinner.
The fix was not to relax the route. It was to give the visitor a permit.
The permit
The page for a game is the only thing that can mint one, and it mints only after the catalogue has already agreed that this game is free tier and implemented. The permit is an HMAC over the game id and an expiry, sent back on a request header.
export const GUEST_PASS_TTL_MS = 3 * 60 * 60 * 1000;
export const GUEST_PASS_HEADER = 'x-guest-pass';
export function mintGuestPass(gameId: string, now: number = Date.now()): string | null {
if (!getGuestPlayableGame(gameId)) return null;
const key = secret();
if (!key) return null;
const expiresAt = now + GUEST_PASS_TTL_MS;
const payload = `${gameId}.${expiresAt}`;
return `${payload}.${sign(payload, key)}`;
}
Three things in that function are worth more than they look.
The guest-playable check is repeated here even though the caller has already done it. The caller passing a game it resolved is not the same as this module deciding what a permit may be issued for. One of those is a convention and the other is enforced by the code that signs.
Verification re-runs the same check. A game can lose its free tier after a permit has been minted, so verifyGuestPass asks the catalogue again rather than trusting a signature that was honest three hours ago. A valid signature over a stale fact is still a stale fact.
Nothing ever falls back to a literal key.
function secret(): string | null {
return (
process.env.GUEST_PASS_SECRET ||
process.env.UNSUBSCRIBE_SECRET ||
process.env.SUPABASE_JWT_SECRET ||
null
);
}
A permit is an access control, so an unset secret must not quietly become a key that every deployment shares. Returning null means every verification fails closed. That costs guests their free game and costs nobody their content, which is the right way round. The chain ends at secrets a working deployment already has, so a correctly configured environment never notices.
The decision I would defend hardest
A capability token is supposed to name the resource it authorises. This one refuses to.
It names the game, not the content pack that game reads. Binding it to the pack needs a reliable game-to-pack map, and the codebase has none: pack ids are literals inside each engine, several engines take the pack as a prop so one component serves many games, and several packs legitimately back more than one game. A map built by inspection gets those cases wrong, and a wrong map breaks a real, paying player's game. That is a worse failure than the one it prevents.
So the gap is bounded by time and rate instead of by scope. A permit lives three hours, names one game, and anonymous reads run on their own tight rate-limit bucket. Pulling the whole library still means repeatedly loading real game pages under those limits rather than reading a public directory, which is the property the authenticated route existed for in the first place.
The general version: when a precise scope check cannot be built correctly, a coarse check plus a short lifetime plus a rate limit is an honest substitute. Shipping an approximate scope map and calling it precise is not.
The ending
Guest play was removed three days later, and with it the permit. The commit that deleted it took out 1,849 lines. The product decision was that an account before the first game is worth more than a frictionless first game, and once nothing can be played without a session, a permit for sessionless play has no reason to exist.
See it for yourself. Open cogniprep.app/games/pymetrics in a signed-out window and inspect the buttons on the game cards. Every one of the twelve is a link to /signup?provider=pymetrics, not a link into the game. That is the design that won. The interesting part is that for three days it was not, and the honest way to build the version that lost was to add a permit rather than to loosen the check it was working around.
If you keep one thing from this: a fail-closed null for a missing signing secret is three lines, and it is the difference between a misconfigured deployment losing a feature and a misconfigured deployment sharing a key with every other misconfigured deployment.
Top comments (0)