DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our cookie policy names four cookies and three storage keys, and I counted what a browser really holds after one visit

CogniPrep's /cookies page makes a specific, checkable promise. It names four cookies:

  • Supabase authentication cookies, up to 400 days
  • a device token, 1 year
  • a country code for currency, 30 days
  • a landing intent, 30 minutes or until you sign in

and three things in browser storage: the theme, the entry intent, and PostHog's identifiers. Then it explains why there is no consent banner: no advertising cookies, no third-party tracking cookies, analytics in local storage rather than cookies, and everything that is set is strictly necessary.

That page was last updated on 12 September. Plenty has shipped since. So I checked it the only way a claim like that can be checked, which is against a browser.

What one visit leaves behind

A signed-out visit to the homepage, read straight out of the console:

document.cookie.split('; ').map(c => c.split('=')[0]);
// ['cp_country']

Object.keys(localStorage);
// ['cogniprep:entry-intent', 'dashboard-games:last-provider',
//  'ph_phc_<project key>_posthog']

Object.keys(sessionStorage);
// ['ph_..._posthog', 'ph_..._primary_window_exists',
//  'ph_..._session_registered_properties', 'ph_..._window_id',
//  'sentryReplaySession']
Enter fullscreen mode Exit fullscreen mode

The headline claims hold up. One cookie for a signed-out visitor, and it is the country code, visible in the response headers without opening a browser at all:

curl -sI https://cogniprep.app/ | grep -i set-cookie
# set-cookie: cp_country=NL; Path=/; Expires=...; Max-Age=2592000; Secure; SameSite=lax
Enter fullscreen mode Exit fullscreen mode

PostHog genuinely sets no cookie. Sentry genuinely sets no cookie. Nothing third-party, nothing for advertising. The reasoning behind "no banner" survives contact with the evidence, which is the part I most expected to be wrong.

The list does not survive it.

Five findings, in order of how much they matter

1. Five cookie names exist in the code, and the policy names four. Grep the constants:

grep -rn "COOKIE = '" --include="*.ts" lib app proxy.ts
# lib/auth/post-auth-destination.ts: LANDING_INTENT_COOKIE = 'landing_intent'
# lib/auth/device-sessions.ts:        DEVICE_TOKEN_COOKIE = 'device_token'
# lib/exercises/spelling.ts:          EXERCISE_SPELLING_COOKIE = 'cp_exercise_spelling'
# lib/pricing/display-currency.ts:    COUNTRY_COOKIE = 'cp_country'
Enter fullscreen mode Exit fullscreen mode

cp_exercise_spelling holds en-GB or en-US. It exists because the assessment centre exercises are written in British English and a candidate heading to a US employer can switch them, and it has to be a cookie rather than a preference row so the server renders the right spelling in the first response. It is as benign as a cookie gets and it is in neither of the policy's two lists. It is set from a signed-in page, so I cannot show it to you with curl, only in the source.

2. Session storage is not mentioned anywhere on the page. The heading is "Browser Local Storage", and five of the eight storage entries above are in sessionStorage. Four are PostHog's and one is sentryReplaySession, which is the vendor telling the next page load that a replay is already in progress. Our own code writes zero session storage keys, so this is entirely a consequence of two vendors the page does describe, in a storage bucket it does not.

3. A referral link writes a key the page does not list. Open the site with a referral parameter on it and watch one appear:

// before: no such key
location.href = 'https://cogniprep.app/?ref=SOMECODE';
// after:
localStorage.getItem('cogniprep:pending-referral');   // the code, banked for 30 days
Enter fullscreen mode Exit fullscreen mode

It also strips the parameter out of the URL with replaceState, so the address bar goes back to https://cogniprep.app/ and the only remaining trace is the key. That is deliberate, documented in the code, and absent from the policy.

4. The dashboard remembers which provider you last opened. dashboard-games:last-provider, written by one component so the games view opens where you left it. The key in my console above was left there by an earlier signed-in visit in the same browser profile: a genuinely fresh profile gets two keys on the homepage, not three. A UI preference, undeclared.

5. The practice screens keep local progress. Counting every localStorage.setItem in the repository gives 17 call sites and 17 distinct keys or key families:

grep -rn "localStorage.setItem" --include="*.ts" --include="*.tsx" app components lib | wc -l
# 17
Enter fullscreen mode Exit fullscreen mode

Twelve of the 17 belong to long practice screens, which keep enough state under a cogniprep: key to offer a resume rather than losing a part-finished run to a refresh or a stray back-navigation. Four of those twelve build their key per screen instead of using a fixed string, which is also why a grep for string literals under-counts them. Of the remaining five, two are the ones the policy names (theme and cogniprep:entry-intent), one is the referral code above, one is the dashboard preference above, and one is cogniprep:admin-call-list, which only exists behind the admin area.

So: 17 keys in our code, two of them named on the page, plus the vendor identifiers it describes. The twelve progress keys are the ones I would most want listed, because they are the only entries holding anything a user would recognise as their own work.

Why the accurate parts are accurate and the list is not

Look at what the page gets right. "Lasts 30 minutes, or until you sign in" for the landing intent is exactly max-age=1800 plus three places that clear it on the way through authentication. "30 days, refreshed on every visit" for the country cookie is exactly what the proxy writes. "No cookies; local storage" for PostHog matches the vendor config. Someone sat down with the code and wrote true sentences.

Then every feature after 12 September added storage without opening that file. A one-line cookie is one line in a component, and the policy page is 281 lines in a different directory. Nothing in the build connects them, so the drift is not a lapse of care, it is the default outcome.

The fix is a test, not a better memory

This repository already has a few hundred tests that assert things about content rather than behaviour: that a page's title is under a length limit, that a particular factual claim appears nowhere in the copy, that every provider in a registry has a page. The same shape works here, and it is the only version of this that survives the next feature:

  1. Collect every storage identifier from the source. Cookie names already live in exported constants, which makes them trivially greppable; the storage keys need the same treatment, which is worth doing for its own sake.
  2. Assert each one appears on the /cookies page, by name or by an explicit grouping the test knows about.
  3. Fail the build when a new key lands without a line in the policy.

That turns a legal page into a checked artefact, and it fails in the pull request that adds the key rather than in an audit a year later. The thing I would not do is write the four missing paragraphs and call it done, because that is the state the page was already in.

See it

Every measurement above is public. Read the cookie policy first, then open DevTools on the homepage and compare: Application, then Storage, gives you the cookie, the local storage keys and the session storage keys in three clicks, and curl -sI https://cogniprep.app/ shows the only Set-Cookie a signed-out visitor receives. Load the pricing page to see the country cookie earning its place, since it is what decides the currency on the cards, and the assessment centre page is the public front of the exercises whose spelling cookie the policy has yet to mention.

Top comments (1)

Collapse
 
omyvnss profile image
Om Yaduvanshi •

the fix being a test rather than a better memory is the line worth keeping. a policy page is a document, and documents rot; failing the build on a new key is the only version of 'accurate' that survives shipping. the cookie names already live in exported constants, so the test is half written.