← writing Build journal · 2026-05-20

A 403, a fake browser, and my own session cookie

I wanted a keyboard shortcut that pops my claude.ai usage stats into a desktop notification. The settings page works fine, but checking it means opening a tab, navigating to settings, switching to the usage panel, and waiting for it to load. I wanted one keypress.

What I thought would be a quick scrape turned into a small tour of how Cloudflare actually decides whether you're a browser — and a reminder that a session cookie is a password. This is the build log: what I did, what broke, and what I now know that I didn't before. The finished tool is on GitHub.

Step zero: is this even possible?

claude.ai doesn't expose a public API for chat usage. The Admin API over on the console covers API consumption — a separate billing surface — not Pro-plan chat usage. So if I wanted this number from a script, scraping was the only path.

Three ways to scrape it: drive a real browser with Playwright (heavy), reverse-engineer the internal API (fragile but light), or reuse the session my logged-in Firefox already has by reading its cookies. The third is the cleanest — no second login, no API key, no automation framework. Just "do what the browser does, but from Python."

Finding the endpoint

claude.ai → DevTools → Network tab → filter to XHR → reload the usage page. About thirty requests came back; one was literally named usage. The URL was https://claude.ai/api/organizations/<org-uuid>/usage, and the response was clean JSON — five_hour, seven_day, and a pile of nulls and internal codename fields (tangelo, iguana_necktie…) that I ignored.

DevTools has a right-click → Copy → Copy as cURL that dumps the entire request — every header and cookie — as a runnable command. Great for reconnaissance. Also the source of my first mistake.

Mistake #1: I leaked my own session cookie

When I copied that cURL command out to look at it, it carried the live sessionKey cookie with it. That cookie is functionally my password — anyone holding it is logged in as me, no credentials needed. The moment I realized it had left a safe place, I logged out of all sessions to invalidate it.

Lesson. Header names are safe to share; header values are not. Before a request shape goes anywhere — a chat, an issue, a blog post — the entire Cookie: line gets redacted. Like this:
curl 'https://claude.ai/api/organizations/XXXX/usage' \
  -H 'User-Agent: Mozilla/5.0 ...' \
  -H 'Cookie: XXXXXXXXXXXXXXXX'   # never the real values

I'm including this mistake on purpose. It's the most useful thing in the whole project: the failure mode for credential leaks isn't malice, it's a convenient "copy" button and not thinking for two seconds.

Mistake #2: HTTP 403, "Just a moment…"

First real run, with cookies loaded and the request built: 403, and a body starting <!DOCTYPE html>…Just a moment…. That's Cloudflare's bot-challenge page. So I checked the obvious things:

  • User-Agent? Matched my real Firefox (confirmed with navigator.userAgent in the console). Not it.
  • Cookies present? Wrote a throwaway script to dump them — sessionKey, cf_clearance, __cf_bm, lastActiveOrg all there. Not it.

The headers were perfect and Cloudflare still said no. The reason turned out to be a layer below the headers entirely: the TLS handshake itself.

The actual lesson: Cloudflare fingerprints the handshake

When any client opens a TLS connection, the very first message (the ClientHello) advertises which cipher suites it supports, in what order, plus a list of TLS extensions. Different software produces characteristically different ClientHellos — and you can hash that shape into a fingerprint. The common name for this is JA3.

Python's requests talks TLS through OpenSSL, whose ClientHello looks nothing like Firefox's. So Cloudflare could see — before a single header was read — that whatever was connecting was not the browser it claimed to be in its User-Agent. Headers said "Firefox," the handshake said "Python." Cloudflare believed the handshake.

The fix is a library called curl_cffi: a near drop-in replacement for requests that runs on libcurl and can imitate a real browser's TLS fingerprint.

from curl_cffi import requests
requests.get(url, headers=HEADERS, cookies=jar, impersonate="firefox133")

That one impersonate argument reshapes the handshake to look like Firefox. Same .get() API, same response object — only the bytes on the wire change. Worked first try.

A detail I left in on purpose. My request headers send a User-Agent string for one Firefox version while impersonate mimics a slightly different one. They don't have to agree — because they're answering different questions. The header is a claim the server reads; the fingerprint is evidence from how the connection was built. Cloudflare cross-checks both, but neither requires the other to match exactly. Realizing the header and the handshake are separate, independently-inspected things is the whole point of this post.

And to be clear about what this is: it's a script reading my own account's data, presenting itself honestly as the browser whose session it's already borrowing. It's not an exploit — it's doing the same handshake my browser does. But it taught me that "looks like a browser" is a much deeper question than setting a User-Agent string, which is a genuinely useful thing to understand from both sides.

Making it survive the real world

A script bound to a keyboard shortcut has no terminal to print errors into, and networks fail. So the fetch is wrapped in a stale-while-error pattern: fresh cache wins if it's within its TTL; otherwise try the network; if the network fails, fall back to any cached data — even expired — and label it stale rather than showing nothing.

fresh cache?      -> use it
else fetch        -> on success, cache it
fetch failed?     -> use stale cache, mark "Stale data (N min old)"
no cache at all?  -> surface a clear error notification

A transient Cloudflare hiccup leaves me looking at slightly old numbers with an honest stale marker, not a broken popup. Around that core there's a config file (so tweaking a threshold isn't a code edit), logging to ~/.cache with crude size-based rotation, and error notifications that say what to actually do ("Open claude.ai in Firefox").

Things I now know that I didn't

  • TLS fingerprinting (JA3) is real, and Cloudflare actually checks it.
  • A session cookie is functionally a password — treat it like one.
  • Snap apps store data under ~/snap/<app>/…, not the usual dotfile paths — which is why Ubuntu's snap-Firefox profile isn't where you'd expect.
  • GNOME custom shortcuts don't inherit your shell environment, so the command needs the venv's Python by absolute path, not bare python3.
  • cf_clearance is bound to your IP and User-Agent — change either and it's invalid even before it expires.

What I'd do differently

Write a five-line spike to confirm the endpoint before designing a whole script around it. I'd have hit the 403 in the first five minutes and learned about TLS fingerprinting before writing a hundred lines that assumed the easy path. And set up .gitignore first, before any pip install — so the venv never exists in git's eyes, even for one commit.

Code: github.com/Daisentaur/claude-usage-notifier ↗
← back to writing