Skip to content
Mehdi AbaakoukMehdi Abaakouk
September 18, 2026 · 18 min read

We deleted a 63 MB GeoIP database and replaced it with a 64-byte signature

We deleted a 63 MB GeoIP database and replaced it with a 64-byte signature

Our device fingerprint never refused a login, so we bound sessions to a WebCrypto key the browser cannot export. What broke, and why Chrome's DBSC should retire most of it.

Our dashboard session cookie was a bearer token. Whoever held it was logged in, from anywhere, on any machine. We spent a long time trying to fix that with device fingerprinting, gave up, and bound the session to a key the browser cannot hand over.

Nobody stole one of our cookies. This was preemptive work on a credential whose only protection was that nobody had gone looking. And by the end of this post I’ll argue we should delete much of what we built, because Chrome’s Device Bound Session Credentials does the job better.

A control you cannot finish rolling out is not a control

The fingerprint system had been in the codebase for a long time. It hashed three request headers and a coarse GeoIP region into a value stored on the session, then compared it on later requests.

It never refused anything. On a mismatch, the server logged the difference and adopted the new fingerprint, with simulate TODOs in the code saying so. The one rejection it could produce was a 403 at the OAuth callback when the X-Client-Fingerprint header was missing, and our own dashboard always sent that header, so no real login ever depended on it.

The rollout stalled at the last step because the signal is too unreliable to enforce. The GeoIP database has holes, and mobile and VPN users are the ones who fall into them: a carrier egress that moves between regions mid-session, or a VPN that makes someone look like they teleported. And every input to the fingerprint travels inside the request. An attacker who copies a cookie jar copies the User-Agent along with it, and picking an exit node in the victim’s region is a menu choice. The signal fails in both directions at once: it flags legitimate users whose network moved, and it waves through the exact attacker it exists to catch.

So the code sat there in permanent observation mode. We kept a 63 MB GeoLite2-City.mmdb checked into the repository, plus the geoip2 dependency and a FingerprintJS bundle in the dashboard, for a check that had never once turned a real session away.

We deleted all of it, and no user noticed.

Signing every request with a key that cannot leave the browser

We do not own the authentication. The mergify-session cookie is a wrapper around a GitHub OAuth token, and GitHub is the identity provider. That rules out the answer most people reach for first: WebAuthn with a platform authenticator. It is hardware-backed and would close the gap I describe further down, but it would make us the authenticator of record for an identity that is not ours. What we can bind is the session sitting in front of somebody else’s token, which happens to be the exact layer DBSC operates on.

So the replacement is proof-of-possession. At login the browser generates an ECDSA P-256 keypair and keeps it:

// extractable = false: the private key's bytes can never be read back out.
const pair = await crypto.subtle.generateKey(ECDSA_PARAMS, false, ['sign']);
await idbRequest((store) => store.put(pair, DEVICE_KEY_ID), 'readwrite');

What makes this survive a page reload: a CryptoKey is structured-cloneable even when it’s non-extractable, so IndexedDB can store the key as an opaque handle. The page gets an object it can sign with and can never serialize.

The keypair and the binding id live in that same store on purpose, so anything clearing one clears the other. A browser that evicts the store, a private window, or a user wiping site data ends up with no binding at all, rather than a fresh key that silently no longer matches the one the server registered. The request then fails the check and the dashboard sends the user back through GitHub OAuth, which mints a new key and a new binding. Losing the key costs a login, not access.

The public half goes to the OAuth callback in an X-Session-Public-Key header. The server validates that the JWK is a P-256 point actually on the curve, stores the canonical form on the session, mints a random binding_id, and hands that id back. Every authenticated request from then on carries a signature over four fields:

def build_signing_payload(*, binding: str, method: str, path: str, timestamp: str) -> bytes:
    return "\n".join((binding, method.upper(), path, timestamp)).encode("utf-8")

Newline-separated because none of the four can contain a newline: the binding is server-issued, the method is an HTTP verb, the path is a URL path, and the timestamp is digits. The method is upper-cased so a client sending get still matches. The signature and its timestamp ride along in X-Session-Proof and X-Session-Proof-TS.

A thief who copies the cookie jar off a laptop gets a cookie and no key, so they cannot produce a valid signature for any request. Copying the whole browser profile is a different story, and I come back to it below.

sequenceDiagram
    participant B as Browser
    participant S as Server
    B->>B: generateKey(ECDSA P-256, extractable=false)
    B->>S: OAuth callback + X-Session-Public-Key (JWK)
    S->>S: validate point is on the curve, store on session
    S-->>B: session_binding_id
    loop every authenticated request
        B->>B: sign(binding, METHOD, path, timestamp)
        B->>S: request + X-Session-Proof + X-Session-Proof-TS
        S->>S: verify against the registered key
    end

WebCrypto and Python disagree about ECDSA signatures

WebCrypto’s ECDSA emits a raw IEEE P1363 signature, which is the r and s values concatenated at fixed width. Python’s cryptography verifies DER. Neither side advertises it. You get a signature, you get a public key, and verification fails with nothing to tell a format mismatch apart from a wrong key.

The conversion is six lines:

def _p1363_to_der(signature: bytes) -> bytes:
    if len(signature) != _P1363_SIGNATURE_BYTES:
        raise MalformedProofError("signature is not 64 bytes")
    r = int.from_bytes(signature[:_COORDINATE_BYTES], "big")
    s = int.from_bytes(signature[_COORDINATE_BYTES:], "big")
    return asym_utils.encode_dss_signature(r, s)

Sixty-four bytes on the wire, two 32-byte coordinates, re-encoded into the format the verifier expects. If you’re wiring WebCrypto to a server-side library and your signatures won’t verify, check this before you go anywhere near your key handling.

What non-extractable actually buys you

This defeats cookie-only theft. An infostealer that copies the cookie database, or a jar lifted from a backup, carries no private key, and there is no serializable key material in it to take.

It does not survive someone copying the whole browser profile. Non-extractable is a property of the JavaScript API, not of the disk. It means the page cannot call exportKey, and the Chromium team’s long-standing position, as I read it, is that these keys are obfuscation rather than a boundary. The key sits in the profile’s IndexedDB files, and Firefox has an open bug asking for CryptoKeys to be cryptographically bound to the profile, which tells you plainly that they are not bound today. An attacker who images an entire profile directory and replays it on another machine should expect a working key. We have not run that attack against ourselves.

So it raises the cost. Cookie theft used to mean grabbing one database. Now it means taking the whole profile and rebuilding enough of the browser around it to use what is inside.

It does not defend against JavaScript running in the live page. Script injected into our own origin can call sign() on the key without ever exporting it, and every request it makes is properly signed. The one gain: there is no portable credential for the payload to carry off, so the attacker has to keep running code in the page instead of walking away with a cookie that works from their own machine. Stopping the injection is still a CSP problem.

The timestamp window is five minutes, which is generous on purpose. It bounds replay of a signature someone already captured, and capturing one does not require breaking TLS: proofs are HTTP headers, and headers end up in edge logs, APM traces, error reports and HAR captures. The window caps that exposure at five minutes.

The payload covers neither the request body nor the query string, so a captured proof replays across every request sharing a method and a path. That is the part I would revisit first.

The routes that skip the check, and the hole next door

Three cases skip the check rather than fail it. WebSockets carry no per-request proof. The routes under /front/auth/ are exempt because that is where the device key gets registered in the first place, so there is nothing to sign against yet. And the provider setup and callback endpoints are exempt for a different reason: they are top-level browser navigations, and you cannot attach a header to one.

A session with no registered key is deliberately not a fourth skip. An old session predating all of this gets refused rather than waved through, and the next login binds it. Getting that backwards would have left a downgrade path open: drop the proof headers, look unbound, get in.

Writing that exemption down made me look at what actually lives on /front/auth/, which is where the real problem was. GET /front/auth/authorize clears the session as its first statement. Our cookie is SameSite=Lax, so a planted cross-site link, or even a same-site <img src>, could reach it with the victim’s cookie attached and log the victim out. Our staff impersonation route has the same shape. It is proof-checked rather than exempt, though, so for accounts with enforcement on, the cross-site version was already refused.

That hole had nothing to do with proof-of-possession. It was the same class as a bug bounty report we had already fixed by making /front/auth/logout POST-only, and it predated this work entirely.

The guard we added covers /front/auth/ and the impersonation routes, deliberately a different list from the proof exemption, and refuses anything on them that is not a same-origin, non-speculative script fetch. The /front/integrations/ callbacks stay outside it, because those are the real navigations and a rule that refused them would break login. Three details in it are load-bearing:

  • It sits outermost on the app, so a refusal never reads or destroys the session it rides on.
  • Sec-Fetch-Site is part of the rule, not just Sec-Fetch-Dest. SameSite=Lax treats every subdomain as same-site, so a page on one could fire a credentialed no-cors fetch() whose Sec-Fetch-Dest looks exactly like ours. Only Sec-Fetch-Site: same-origin tells them apart.
  • A request with no Sec-Fetch-* headers is allowed through. Their absence is not something an attacker’s page can arrange, and a request with no browser behind it carries no victim cookie to abuse. This does fail open: a client too old to send the headers, or a proxy that strips them, gets nothing from this guard. In practice, that means Safari versions older than 2023. The same gap applies to any deployment served over plain HTTP, because browsers only send these headers to trustworthy origins, which is one more reason on-prem installs should terminate TLS.

The textbook fixes did not cover this prefix. Making a route POST-only works one route at a time. The same app also serves the OAuth setup popup, the provider redirect_uri, and the Stripe checkout hand-off, which are top-level navigations on purpose, so a blanket SameSite=Strict breaks flows we need. A CSRF token would have to be threaded through every route on the prefix and kept there. The Sec-Fetch rule covers the whole prefix without per-route state.

We checked it against a real Chrome and through a wrangler dev worker running the same proxy pattern as our production Cloudflare worker, mostly to confirm that Cloudflare forwards Sec-Fetch-* upstream. If it had stripped them, the guard would have been a silent no-op in production and passed every test we had.

What didn’t work

Our tests were verifying a request shape that doesn’t exist. The middleware rebuilt the signed path as scope["root_path"] + scope["path"]. Starlette (like the ASGI spec) sets root_path to the /front mount but does not strip the prefix from path, so the server was verifying /front/front/... against a browser that had signed /front/.... Every proof failed. With enforcement off that was harmless noise in the logs. For the one internal account where it was on, it was a 401, re-login, 401 loop with no way out. The same doubling stopped the exempt prefixes from matching, so the login routes that should have skipped the check were asked for proofs too.

The unit tests had passed the entire time because they hand-built an ASGI scope with a mount-relative path, and that is not what Starlette produces. They were self-consistent, and they described a different web framework. They now use the real shape, so the same mistake fails them.

I reinvented a subset of DPoP without knowing it existed. RFC 9449 standardizes almost exactly this: a client-held key, a per-request proof, claims covering the HTTP method (htm), the URI (htu), and a timestamp (iat). Our four fields map onto it nearly one to one, though the binding id is not a jti: it is a per-session constant naming the registered key, closer to DPoP’s cnf thumbprint than to a per-proof nonce.

It also covers three things we do not have. We keep no server-side record of spent proofs, so a captured proof can be replayed for the whole five-minute window, where DPoP servers are expected to track jti and accept each proof once. We sign the path but not the host, where DPoP’s htu covers scheme, host and path (though, like ours, not the query or body). And nothing of ours corresponds to ath, which pins a proof to one specific token. Each of those is defensible against an attacker holding only a cookie. None of them is defensible as a decision, because I never made one.

DPoP is application-level: you build the proof yourself with WebCrypto, exactly like we did, and no browser needs to know about it. If you’re about to build session binding, read RFC 9449 first and decide deliberately whether you want the JWT machinery.

We broke running the local dashboard against production. That flow worked by pasting a session cookie into a local build, and a pasted cookie has no matching key, so every local request failed as missing. The obvious fix was a bypass flag in dev. We did not take it. Instead a staff-only endpoint registers the local browser’s key onto a fresh session, so local development signs real proofs against production.

Dev-bind landed after the root_path fix, so it caught nothing, but a local build that signs real proofs would have. A bypass would have hidden exactly the class of bug we most needed to see. The cost is real: this path trusts a per-account staff secret, so a compromised staff laptop is a risk for that one account. It is staff-only, per-account and logged, and no customer can reach it.

Results

There is no before-and-after chart here, because there was no attack. What changed is the class of the credential. A cookie copied from one of our users’ machines no longer reaches our API on its own, where before it was a complete login. The check is enforced for every account.

Every authenticated request pays an IndexedDB read and an ECDSA signature in the browser. On the server the parsed key is cached, so verification costs almost nothing. We never measured the client half and nobody has complained.

Enforcement is keyed per account, and we ran every account in counting mode before we turned the flag on. In counting mode, the server verifies the proof, tags any failure by cause, and lets the request through anyway. That ordering is also what kept the root_path bug cheap. With enforcement off it was log-only noise, so a bug that failed every proof in the system had a blast radius of one internal account stuck in a login loop, found by testing rather than by a customer. The flag is keyed by the logged-in user’s account rather than the organization, because verification happens before any org is resolved, and refusing a session logs its user out of everywhere at once.

Counting mode is exactly where the fingerprint check spent its entire life, so anyone can point at this and call it the same thing. What makes it different is what ends it. A signature either verifies or it does not, and there is no group of legitimate users structurally unable to produce one, the way mobile and VPN users could never produce a stable region.

The part we plan to delete

Chrome shipped Device Bound Session Credentials on Windows in version 146, backed by the TPM, and has been rolling it out gradually on macOS since Chrome 147 (Google’s write-up). DBSC does the same job at the browser level, and it does it better in two ways.

It signs a session refresh rather than every request. Login returns a long-lived cookie plus a Secure-Session-Registration header, the browser generates a key in hardware and posts the public half to a registration endpoint you nominate (/StartSession in Chrome’s example), and you swap in a short-lived cookie. When that expires, the browser sends the session id, you answer 403 with a challenge, it signs the challenge, and you issue a fresh cookie. The deferred request resumes on its own.

sequenceDiagram
    participant B as Browser
    participant S as Server
    S-->>B: login + Secure-Session-Registration
    B->>B: generate key in TPM / Secure Enclave
    B->>S: POST /StartSession (public key as JWT)
    S-->>B: short-lived cookie + refresh config
    Note over B,S: ~10 minutes later
    B->>S: Sec-Secure-Session-Id
    S-->>B: 403 + Secure-Session-Challenge
    B->>S: Secure-Session-Response (signed)
    S-->>B: refreshed cookie

Per-request cost is zero, and the key lives in hardware rather than in a directory on disk. That is the gap from earlier in this post: against an attacker who can copy a profile, a software key is a speed bump and a TPM key is a wall.

Fingerprint What we built DBSC
What is bound three headers and a GeoIP region a software key in IndexedDB a key in the TPM or Secure Enclave
Ever refused anything never a real login yes, every account yes
Survives a copied cookie no yes yes
Survives a copied profile no probably not yes
Survives XSS no no no
Client code needed a fingerprinting bundle a wrapper on every request none
Works in Firefox and Safari yes yes no

The second advantage is the one I actually care about. DBSC needs no application client code. Every hard part of what we built exists only because ours lives in the app: the fetch wrapper around every API, Octokit, and GraphQL call, the WebSocket exemption, the exemption for routes that have no binding yet, the IndexedDB persistence, the signing-payload path bug, and the staff dev-binding hatch. A browser-level protocol has none of those, because it sits below the place where our application makes requests.

The Sec-Fetch guard is deliberately not on that list. A DBSC-bound cookie still rides a top-level navigation under SameSite=Lax, so that CSRF work was owed whichever mechanism we ran.

So we are not going to run this forever. We will wait for browser support to be good enough, probably run both during the overlap, and delete the client half when the coverage justifies it. How long that takes is not up to us, and our own numbers say where the ceiling is. Over the last two weeks, 72% of dashboard sessions were Chrome and about 22% were Firefox or Safari, neither of which has shipped DBSC or said it intends to.

What survives is larger than I would like: verifying an ECDSA P-256 signature against a key stored on the session, and the per-account enforcement flag. DBSC also needs a registration endpoint, challenge issuance, and short-lived cookie minting, with JWTs for the registration key and the signed proofs, which is exactly the machinery I skipped.

DBSC does not fix XSS either. It bounds the damage to one cookie lifetime once the injected script is gone, which for us would mean minutes instead of the 24 hours our session currently lasts, but during the compromise the session works fine.

Advice

If a security check has lived in observation mode for years, ask whether any threshold could ever let you enforce it. If the attacker can replay the signal, none can. See what those logs have actually been worth, then delete it. Nobody will notice, because it never refused anyone.

If you’re about to bind sessions yourself, read RFC 9449 before you design a payload, and start from a short-lived cookie with one signed refresh endpoint. Compared with signing every request, it costs you one cookie lifetime of exposure to a stolen cookie, and it gives you the shape DBSC already has. Whatever client code you write, roughly one session in five will still need it long after Chrome stops needing it. Write as little of it as you can.

Stay ahead in CI/CD

Blog posts, release news, and automation tips straight in your inbox.

Recommended posts

Two Logins, One Tab, One Cache Key
September 16, 2026·8 min read

Two Logins, One Tab, One Cache Key

One sessionStorage key for every login meant the next person on a tab briefly saw the previous account's profile. Naming the cache after the login fixed it, at a price.

Alexandre GaubertAlexandre Gaubert
We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It
July 29, 2026·8 min read

We Stopped Our Config Builder From Destroying Your YAML, Then Found a Worse Bug Next to It

Our no-code config builder used to strip comments and anchors out of your .mergify.yml on every commit. Fixing it meant patching the document in place instead of re-dumping it, and an audit of the code next door turned up a parser bug that had been silently inverting a security condition in production.

Alexandre GaubertAlexandre Gaubert
The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.
July 27, 2026·7 min read

The Native TypeScript Compiler Cut Our Typecheck From 13s to 3.5s. The Only Hard Part Was ESLint.

We swapped in TypeScript 7's native Go compiler and our dashboard typecheck went from 13s to 3.5s. The compiler was nearly a drop-in. The real work was keeping typescript-eslint alive when native 7.0 ships no importable compiler API.

Thomas BerdyThomas Berdy