Library / SDK
deedy5/primp avatar
deedy5/primp

primp: an HTTP client that borrows a browser's TLS fingerprint

HTTP client that can impersonate web browsers

618 stars67 forksRustMIT

At a glance

What is it?
primp is a Rust HTTP client and Python binding that presents the TLS and HTTP2 fingerprints of a named browser profile, with versioned Chrome, Safari, Edge, Firefox and Opera profiles plus a random mode.
Who is it for?
Adopt primp if your HTTP requests are being rejected on their TLS or HTTP2 fingerprint rather than their content, and start with the profile that matches a real user agent. Do not treat it as a way around access controls on sites you have no permission to crawl, and note that the project ships a disclaimer saying it is for educational purposes only.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 40 days ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The fingerprint is the point, not the user agent

primp is described in one line as an HTTP client that can impersonate web browsers, and the interesting part is what it changes. A normal HTTP client sends a request with a user agent header and a TLS handshake that carries its own characteristics, and the headers and the handshake rarely match what a browser would produce. Servers that fingerprint clients look at that combination. The topic list attached to the repository names the vocabulary directly: TLS fingerprints, JA3 and JA4, HTTP2 fingerprints and Akamai fingerprinting, alongside web scraping, curl and Python requests. So the library sits in the same family as curl-impersonate, with the difference that it ships both a Rust crate and a Python package, and that it exposes the profile choice as an explicit constructor argument rather than leaving it to a configuration file. The example in the README points a request at a service that reports the connection details back, which is the quickest way to see what the handshake looks like.

Two front doors: a Rust crate and a Python binding

The project is a Cargo workspace with two user facing packages. The Rust side lives in crates/primp and is used through a builder that takes an impersonation target:

rust
use primp::{Client, Impersonate};

#[tokio::main]
async fn main() -> Result<(), primp::Error> {
    let client = Client::builder()
        .impersonate(Impersonate::ChromeV146)
        .build()?;
    let resp = client.get("https://tls.peet.ws/api/all").send().await?;
    println!("Body: {}", resp.text().await?);
    Ok(())
}

The Python side lives in crates/primp-python and is installed from PyPI:

bash
pip install primp

and used with a string profile name:

python
import primp

client = primp.Client(impersonate="chrome_146")
resp = client.get("https://tls.peet.ws/api/all")
print(resp.text)

Two details are worth pausing on. The Python binding is not a thin wrapper around a subprocess or a port of the Rust code, it is a compiled extension, which is why the workspace has to compile a cdylib for it. And the Python API takes the profile as a lowercase string while the Rust API takes an enum variant, so the two surfaces are not interchangeable even though they cover the same set of browsers. The client is asynchronous in Rust, which means it fits into an existing Tokio application without a thread pool of its own.

Versioned profiles for five browser families, plus a random one

The profile table is the heart of the library, and it is versioned rather than generic. Chrome runs from chrome_144 through chrome_153 with a plain chrome entry, Safari lists safari_18.5, safari_26, safari_26.3, safari_26.4 and safari, Edge mirrors Chrome's numbering from edge_144 to edge_153 with edge, Firefox lists firefox_140, firefox_146 through firefox_151 and firefox, and Opera lists opera_126 through opera_135 with opera. There is also a random profile. The granularity matters more than it looks: a fingerprint captured from Chrome 145 and one from Chrome 146 differ, and a server that keeps a history of what a given fingerprint claims to be can spot a mismatch. That is why a library offering ten Chrome profiles rather than one is a different proposition from a user agent switcher. The random option is the one to treat carefully, because a random profile that disagrees with the headers you send elsewhere in your request is exactly the kind of inconsistency the library exists to avoid.

Operating system hints are part of the profile

Separately from the browser profiles, the README lists the operating system values that can be combined with them: android, ios, linux, macos, windows and random. A browser fingerprint does not exist in isolation, since the same Chrome build produces a different handshake depending on whether it runs on Windows, macOS, Linux or Android, and the operating system can leak through the TLS extension values and the HTTP2 settings that follow from the platform network stack. So the profile is really a pair, a browser and an operating system, and choosing them separately means the caller can assemble a combination that no real browser ever produced. The right approach is to pick the pair that matches the client you are actually emulating rather than treating the two lists as independent knobs, because a mismatched pair defeats the purpose of the library.

A workspace split into the client, TLS, HTTP2 and Python crates

The Cargo.toml shows how the project is factored. The workspace declares four members: crates/primp, crates/primp-rustls/rustls, crates/primp-h2 and crates/primp-python, with a modern resolver set to version 3. The naming makes the layering obvious, a TLS layer built on rustls, a separate HTTP2 crate, the client that ties them together, and the Python extension. Splitting the fingerprinting code out this way is what allows a caller to change one layer without forking the other, and it is also what lets the Python package reuse the same implementation instead of reimplementing the handshake in Python. A Cargo.lock is committed at the top level, which pins the dependency graph for reproducible builds, and the repository has a .github directory for the workflow configuration. The project is MIT licensed, its default branch is main, and the last push was on 2026-09-13, with v2.0.1 released on 2026-09-12 after v2.0.0 on 2026-08-26 and v1.3.1 on 2026-05-23.

The release profile is tuned around a cdylib that must not kill the interpreter

The release profile in the workspace file is where the Python binding shapes the whole build. Optimization is turned all the way up, with codegen-units set to 1, fat link time optimisation and opt-level 3, and symbols are stripped from the artefacts. The one setting that looks ordinary but is not is panic = unwind, and the comment next to it explains why: it is required for the primp-python cdylib so that panics become Python exceptions, and it has to be applied workspace-wide because cdylib dependencies must share the strategy. The comment also draws the line honestly, since out of memory conditions and stack overflow still abort rather than raise. That is a genuine constraint on how a Python program can use this library, because an abort takes the interpreter down instead of raising an exception you can catch. The bench profile deliberately turns fat link time optimisation off and raises codegen-units to 256, so benchmarking does not measure a build nobody will ship.

The disclaimer is short and it is the first thing to read

The README ends with a disclaimer that this tool is for educational purposes only and that you should use it at your own risk. That sentence sits under a heading of its own, which tells you the authors consider it significant rather than boilerplate. The library has obvious legitimate uses, since a company testing its own site or its own bot defences needs to know what a browser looks like from the network side, and the same code is what security teams use to check whether their fingerprinting blocks anything. What the library cannot do is make unauthorised access acceptable. Bypassing a block on a site you do not have permission to crawl is a different act from testing your own defences, and the licence and the disclaimer do not change that. The profile set also ages quickly, since a new browser release invalidates the corresponding fingerprint, and the README lists versions up to chrome_153, so a deployment that pins an old profile drifts further from real traffic over time.

Editorial conclusion

Adopt primp if your HTTP requests are being rejected on their TLS or HTTP2 fingerprint rather than their content, and start with the profile that matches a real user agent. Do not treat it as a way around access controls on sites you have no permission to crawl, and note that the project ships a disclaimer saying it is for educational purposes only.

Frequently asked questions

How do I install and use primp in Python?

Install the package with pip install primp, create a client with primp.Client(impersonate="chrome_146") and make requests with it. The Python package is a compiled extension built from the same Rust implementation as the crate.

Which browser profiles does primp support?

The README lists versioned profiles for Chrome from chrome_144 to chrome_153, Safari from safari_18.5 to safari_26.4, Edge from edge_144 to edge_153, Firefox from firefox_140 to firefox_151 and Opera from opera_126 to opera_135, each with a generic entry, plus a random profile.

What operating system values can be combined with a primp profile?

The README lists android, ios, linux, macos, windows and random. The operating system is a separate choice from the browser profile, so both parts of the impersonation have to agree to match real browser traffic.

What is the difference between the Rust and Python APIs of primp?

The Rust crate takes an enum value such as Impersonate::ChromeV146 through a client builder, while the Python package takes a lowercase string such as chrome_146. Both are served by the same Cargo workspace, which also contains separate crates for the rustls TLS layer and for HTTP2.

Why does the primp workspace set panic = unwind?

Because the primp-python crate is a cdylib, and unwinding is what lets a panic become a Python exception. The setting has to be workspace-wide since cdylib dependencies must share the strategy, and out of memory or stack conditions still abort rather than raise.

Official sources

  1. deedy5/primp on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/deedy5-primp.svg)](https://hysenlabs.com/projects/deedy5-primp)

Was this useful?