pybrws

Making Python look like a browser

Three write-ups about a thing I did not know existed until a scraper of mine started getting 403s it should not have.

Ernesto Pérez Costa · October 2026

Short version of how this started: a URL that a plain Python client gets a 403 on loads fine in Firefox, on the same machine, over the same connection. I copied the browser's headers out of devtools until a field-by-field diff of the two requests came back empty, and it was still a 403. So the server was not reacting to anything in the request. It had decided earlier — from the shape of the TLS handshake, before any HTTP exists, which is not something you can change from Python's ssl module.

So I wrote the handshake. Then TLS 1.2 as well, then HTTP/1.1, HTTP/2, and eventually HTTP/3 over QUIC, with profiles for Firefox 157 and Chrome 155 built out of my own packet captures. These three posts are the parts worth telling, including the bits I got wrong.

PART ONE

Making Python impersonate Firefox

1,901 bytes, 70% of them one extension I could not simply copy. Which fields are allowed to vary and how I found out. And the afternoon I discovered my own antivirus had been rewriting the browser's fingerprint the whole time.

PART TWO

Impersonating Chrome, which never sends the same thing twice

Six captures, six different extension orders, and two GREASE values pinned at the ends that collide one time in sixteen. Then a day of failed hypotheses on a site that still refused me, and the answer sitting in a sentence I had already been given.

PART THREE

Firefox has two TLS fingerprints, and they disagree

QUIC handshakes can be decrypted by anyone, with a key printed in the RFC. I read my own network for a few minutes and found that Firefox shuffles over QUIC and not over TCP — plus three readings I had to take back.

The client itself

from pybrws.client import get

r = get("https://example.com", profile="chrome155")
LayerWhat is in it
TLS1.2 and 1.3 — handshake, records, key schedule, resumption, HelloRetryRequest, client certificates
TransportHTTP/1.1, HTTP/2, HTTP/3 over QUIC — each sending its profile's own settings, frame order and header order
Plumbingcookies, sessions, connection pool, proxies, redirects, streaming, content decoding
ProfilesFirefox 157 over TCP, Firefox 157 over QUIC, Chrome 155 — each measured separately

It is built on cryptography for the primitives and aioquic for QUIC's transport, and nothing else. Everything whose shape is a fingerprint is written here.

If you just want working requests, use curl_cffi — it links the actual libraries browsers use, and it is faster and more complete than this will ever be. I wrote this to understand the problem, and because when a target starts keying on something your library does not reproduce, somebody has to be able to work out what.

It does not run JavaScript, so a "checking your browser" page is out of reach by design. That boundary comes up in part two and it is the honest limit of all of this.