PINGDOM_CHECK

#ExtractSummit2026 The world's largest web scraping conference returns. Austin Oct 7–8 · Dublin Nov 10–11.

Register now
Data Services
Pricing
Login
Try Zyte APIContact Sales
  • Unblocking and Extraction

    Zyte API

    The ultimate API for web scraping. Avoid website bans and access a headless browser or AI Parsing

    Ban Handling

    Headless Browser

    AI Extraction

    SERP

    Enterprise

    DocumentationSupport

    Hosting and Deployment

    Scrapy Cloud

    Run, monitor, and control your Scrapy spiders however you want to.

    Coding Agent Add-Ons

    Agentic Web Data

    Plugins that give coding agents the context to build production Scrapy projects. Starts with Claude Code.

  • Data Services
  • Pricing
  • Browse

    • BlogArticles, podcasts, videos
    • Case studiesCustomer outcomes
    • White papersIn-depth reports
    • DocumentationGuides & API reference
    • EventsConferences, webinars, recordings

    Subscribe

    • NewsletterSwiftly delivered
    • Join our community2,000+ web scraping engineers
  • Product and E-commerce

    From e-commerce and online marketplaces

    Data for AI

    Collect and structure web data to feed AI

    Job Posting

    From job boards and recruitment websites

    Real Estate

    From Listings portals and specialist websites

    News and Article

    From online publishers and news websites

    Search

    Search engine results page data (SERP)

    Social Media

    From social media platforms online

  • Meet Zyte

    Our story, people and values

    Contact us

    Get in touch

    Support

    Knowledge base and raise support tickets

    Terms and Policies

    Accept our terms and policies

    Open Source

    Our open source projects and contributions

    Web Data Compliance

    Guidelines and resources for compliant web data collection

    Join the team building the future of web data
    We're Hiring
    Trust Center
    Security, compliance & certifications
Login
Try Zyte APIContact Sales
All articles
AI71, 71 articles
Data quality15, 15 articles
Developer interest59, 59 articles
Integration2, 2 articles
Open-source50, 50 articles
Proxies35, 35 articles
Scraping practice35, 35 articles
Scraping strategy47, 47 articles
Search results4, 4 articles
Web data74, 74 articles
Web scraping APIs49, 49 articles
Scrapy47, 47 articles
Scrapy Cloud26, 26 articles
Web Scraping Copilot11, 11 articles
Zyte API67, 67 articles
AI & Machine Learning3, 3 articles
Automotive3, 3 articles
E-commerce & retail33, 33 articles
Entertainment & Streaming2, 2 articles
Financial Services8, 8 articles
Government2, 2 articles
Market Research & Intelligence7, 7 articles
Media & publishing11, 11 articles
Real Estate2, 2 articles
Recruitment & HR3, 3 articles
Transportation & Logistics2, 2 articles
Travel & hospitality3, 3 articles
iPaaS2, 2 articles
Large language model29, 29 articles
MCP3, 3 articles
Python110, 110 articles
Scraping at Scale7, 7 articles
Scraping Fundamentals11, 11 articles
Web Scraping Industry Report20, 20 articles

Appearance

Discord Community
BlogLearnWhy Python Requests gets "403 Forbidden"
LearnAnti-banWeb scraping APIs

Why Python Requests gets "403 Forbidden"

J

John Rooney

·

6 min read · March 2, 2026

Why Python Requests gets a 403 Forbidden response

TLS Fingerprinting

If you’ve had your HTTP request blocked regardless of using correct headers, cookies, and good IPs, there’s a chance you are running into one of the simplest forms of blocking, and one of the most confusing for beginners.

In fact, once I showed this to some developers at Extract Summit, they couldn’t believe how straightforward it was to fix.

This is especially prevalent if you’ve followed my guide on modern webscraping. You found the hidden API, and your request works perfectly in Postman... but it fails instantly within your Python code.

Your TLS Fingerprint

To use an analogy: We’ve effectively written a different name on a sticker and stuck it to our t-shirt, hoping to get past the bouncer at a bar.

  • The Nametag (Headers): Says "Chrome."

  • The T-Shirt Logo (TLS Handshake): Very obviously says "Python."

This mismatch is spotted immediately. We need to change our t-shirt to match the nametag.

To understand how they spot the logo, we need to look at the initial “Client Hello” packet. There are 3 key pieces of information exchanged here:

  1. Cipher Suites: The encryption methods the client supports.

  2. TLS Extensions: Extra features (like specific elliptic curves).

  3. Key Exchange Algorithms: How they agree on a password.

To draw a "Python Logo," the colors (Ciphers), extensions (shapes), and key algorithms (logo placement) are completely different from what it takes to draw a "Chrome Logo."

This is because Python’s requests library uses OpenSSL, while Chrome uses Google's BoringSSL. While they share some underlying logic, their signatures are notably different. And that’s the problem.

OpenSSL vs. BoringSSL

The root cause of this mismatch lies in the underlying libraries.

Python’s requests library relies on OpenSSL, the standard cryptographic library found on almost every Linux server. It is robust, predictable, and remarkably consistent.

Chrome, however, uses BoringSSL—Google’s own fork of OpenSSL. BoringSSL is designed specifically for the chaotic nature of the web and it behaves very differently.

The biggest giveaway between the two is a mechanism called GREASE (Generate Random Extensions And Sustain Extensibility).

json

Chrome (BoringSSL) intentionally inserts random, garbage values into the TLS handshake—specifically in the Cipher Suites and Extensions lists. It does this to "grease the joints" of the internet, ensuring that servers don't crash when they encounter unknown future parameters.

This is one of the key changes

  • Chrome: Always includes these random GREASE values (e.g., 0x0a0a).

  • Python (OpenSSL): Never includes them. It only sends valid, known ciphers.

So, when an anti-bot system sees a handshake claiming to be "Chrome 120" but lacking these random GREASE values, it knows instantly that it is dealing with a script. It’s not just that your shirt has the wrong logo; it’s that your shirt is too clean.

JA3 Hash

Anti-bot companies take all that handshake data and combine it into a single string called a JA3 Fingerprint.

Salesforce invented this years ago to detect malware, but it found its way into our industry as a simple, effective way to fingerprint HTTP requests. Security vendors have built databases of these fingerprints.

It is relatively straightforward to identify and block any request coming from Python’s default library because its JA3 hash is static and well-known.

This code snippet would yield the below JSON response.

python

Note the lack of akamai_hash:

json

Putting the above JA3 hash into ja3.zone clearly shows this is a python3 request, using urllib3:

What’s the solution?

As mentioned, simply changing headers and IP addresses won’t make a difference, as these are not part of the TLS handshake. We need to change the Ciphers and Extensions to be like what a browser would send.

The best way to achieve this in Python is to swap requests for a modern, TLS-friendly library like curl_cffi or rnet.

These libraries wrap low-level C code to spoof the browser's handshake. Here is how easy it is to switch:

python

json

By adding that impersonate parameter, you are effectively putting on the correct t-shirt.

Summary

Make curl_cffi or rnet your default HTTP library in Python. This should be your first port of call before spinning up a full headless browser.

A simple change (which brings benefits like async capabilities) means you don’t fall foul of TLS fingerprinting. curl-cffi even has a requests-like API, meaning it's often a drop-in replacement.

However, if changing the handshake doesn’t fit your use case, you might need to look at using a headless browser.

Zyte's Solution

Zyte API handles anti-bot management for you, automatically selecting optimum tactics and tools for each site, so you can skip the guesswork and scale instantly with confidence. This includes all of the fingerprinting issues we've covered in this post, and much much more.

Try Zyte API for free

FAQs

What is a JA3 Hash?

JA3 is a method for creating a digital "fingerprint" of your TLS client. It takes technical details from your "Client Hello" packet—such as the Cipher Suites you support and the specific order they are listed in—and turns them into a short string (hash).

  • Standard Python requests has a very common, static JA3 hash that is easily blacklisted.

  • Real Browsers have complex, varying hashes that include "GREASE" (random garbage data) to ensure compatibility.

Which Python library should I use to fix this?

We recommend swapping the standard requests library for curl_cffi or rnet.

  • curl_cffi allows you to pass an impersonate="chrome" parameter, which automatically makes your TLS handshake identical to a real browser. It is often a drop-in replacement for requests.

Does using Selenium or Playwright fix this?

Yes. Because Selenium and Playwright control a real browser binary, they naturally generate a valid browser TLS fingerprint. However, they are much slower and more resource-intensive than using a specialized HTTP client like curl_cffi.

Try these simple changes

3 Simple and open source changes to your code to help you avoid bans

Watch now

What is Hybrid Scraping?

..and how can it help my scrape more, and scrape faster?

Read now

What we think of Gemini 3 pro

..and how it is useful for webscraping (spoiler: very much so)

Read now

In this article

  • Why Python Requests gets a 403 Forbidden response
  • TLS Fingerprinting
  • Your TLS Fingerprint
  • OpenSSL vs. BoringSSL
  • JA3 Hash
  • What’s the solution?
  • Summary
  • Zyte's Solution
  • FAQs
  • What is a JA3 Hash?
  • Which Python library should I use to fix this?
  • Does using Selenium or Playwright fix this?
  • Try these simple changes
  • What is Hybrid Scraping?
  • What we think of Gemini 3 pro

Other lessons

Learn Scrapy

  • Scrapy Tutorial Part 1: First Spider
  • Scrapy Tutorial Part 2: Page Objects
  • Scrapy Tutorial Part 3: Web Scraping CoPilot

What is web scraping?

  • What Is Web Scraping?
  • What are the elements of a web scraping project?
  • Python Web Scaping Tools & Libraries
  • How to architect a web scraping solution: The step-by-step guide
  • Web crawling vs web scraping
  • Is Web & Data Scraping Legally Allowed?
  • Compliant Web Scraping Checklist
  • Best practices for web scraping
  • A Guide to Web Scraping With Java
  • Transition from Zenrows to Zyte API
  • Guide to Web Scraping APIs
  • Screen Scraping Explained
  • Large Scale Web Scraping with Python
  • Large Scale Web Scraping with Python
  • Building a Web Crawler in Python
  • A Practical Guide to XML Parsing with Python
  • Learn How to Scrape a Website
  • Advanced Use Cases for Session Management
  • Golang Web Scraping in 2025
  • Web Scraping Dynamic Websites With Zyte API
  • What is Data Parsing in Web Scraping?
  • Scrape Web Pages and Files Using Python, wget, and Zyte

Web Scraping How-to Videos

  • Web scraping videos

SERP Data Collection at Scale

  • SERP data collection at scale and why efficiency matters
  • Why Page One SERP data is no longer enough
  • Why pagination logic becomes operational debt
  • Why SERP data costs exploded

What is web scraping used for?

  • What is web scraping used for?
  • Pricing Intelligence Web Scraping
  • Web Scraping For Market Research
  • Use web scraping to build a data-driven product
  • Use web scraping for alternative data for finance
  • Use web scraping for brand monitoring
  • Use web scraping to automate MAP compliance
  • Web Scraping For Lead Generation
  • Web Scraping For Recruitment
  • Use web scraping for business automation
  • Using Data Extraction Tools for Efficient Website Scraping
  • Why Might a Business Use Web Scraping to Collect Data?
  • How to Scrape Images from Any Website: A Complete Guide
  • How to Scrape Search Engine Results

The New Guide to Web Scraping at Scale

  • Introduction
  • 1. A plan is a pathway to success
  • 2. Get serious about legal compliance
  • 3. The quality of your web data is of utmost importance
  • 4. Scaling and maintaining crawling and extracting solutions
  • 5. Adding AI to the web scraping stack
  • 6. The In-house vs outsourced question
  • 7. Questions to ask when scaling web scraping

Essential Web Scraping Techniques

  • TLS Fingerprint and how it blocks requests
  • How to scrape with a browser effectively
  • API First data extraction

More learn articles

Keep learning

All learn articles →
What are residential proxies bannerUse case

What is a residential proxy?

Learn what residential proxies are, how they compare to datacenter proxies, and why modern web scraping needs more than IP diversity.

10 min read

Zyte Case Studies — every customer story, in one placeUse case

How much do rotating proxies cost?

Learn how much rotating proxies cost, what affects pricing, and why total web scraping costs often go beyond proxy subscriptions.

10 min read

Zyte Case Studies — every customer story, in one placeUse case

How do rotating proxies work?

Learn how rotating proxies work, when to use them for web scraping, and why IP rotation alone is not enough for reliable data access.

10 min read

Services

Zyte Data

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Explore Zyte Data

Web Scraping API

Zyte API

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Sign Up

Developers

Zyte Developers

Coding tools & hacks straight to your inbox. Bi-weekly dosage of all things code.

Join Us
    • Zyte API
    • Ban Handling
    • AI Extraction
    • SERP
    • Enterprise
    • Scrapy Cloud
    • Agentic Web Data
    • Pricing
1[
2  "TLS_GREASE (0xFAFA)",
3   ....
4]
Copy
1def get_ja3_info():
2    url = "https://tls.peet.ws/api/clean"
3    with requests.Session() as session:
4        response = session.get(url)
5        response.raise_for_status()
6        data = response.json()
7        print(json.dumps(data))
Copy
1{
2  "ja3": 
3"771,4866-4867-4865-49196-49200-49195-49199-52393-52392-49188-49192-49187-49191-159-158-107-103-255,0-11-10-16-22-2
43-49-13-43-45-51-21,29-23-30-25-24-256-257-258-259-260,0-1-2",
5  "ja3_hash": "a48c0d5f95b1ef98f560f324fd275da1",
6  "ja4": "t13d1812h1_85036bcba153_375ca2c5e164",
7  "ja4_r": 
8"t13d1812h1_0067,006b,009e,009f,00ff,1301,1302,1303,c023,c024,c027,c028,c02b,c02c,c02f,c030,cca8,cca9_000a,000b,000
9d,0016,0017,002b,002d,0031,0033_0403,0503,0603,0807,0808,0809,080a,080b,0804,0805,0806,0401,0501,0601,0303,0301,030
102,0402,0502,0602",
11  "akamai": "-",
12  "akamai_hash": "-",
13  "peetprint": 
14"772-771|1.1|29-23-30-25-24-256-257-258-259-260|1027-1283-1539-2055-2056-2057-2058-2059-2052-2053-2054-1025-1281-15
1537-771-769-770-1026-1282-1538|1||4866-4867-4865-49196-49200-49195-49199-52393-52392-49188-49192-49187-49191-159-158
16-107-103-255|0-10-11-13-16-21-22-23-43-45-49-51",
17  "peetprint_hash": "76017c4a71b7a055fb2a9a5f70f05112"
18}
Copy
1from curl_cffi import requests
2# note the impersonate argument & import above
3def get_ja3_info():
4    url = "https://tls.peet.ws/api/clean"
5    with requests.Session() as session:
6        response = session.get(url, impersonate="chrome")
7        response.raise_for_status()
8        data = response.json()
9        print(json.dumps(data))
Copy
1"akamai_hash": "52d84b11737d980aef856699f885ca86"
Copy
    • Product & E-commerce
    • Data for AI
    • Job Posting
    • Real Estate
    • News & Articles
    • Search
    • Social Media
    • Blog
    • Learn
    • Case Studies
    • Webinars
    • White Papers
    • Join our community
    • Documentation
    • Meet Zyte
    • Contact us
    • Jobs
    • Support
    • Terms and Policies
    • Trust Center
    • Do not sell
    • Cookie settings
    • Web Data Compliance
    • Open Source
    • What is Web Scraping
    • Web Scraping in Python: Ultimate Guide
    • Stop getting blocked, start scraping
  • EWDCI logoMost loved workplace certificateZyte rewardISO 27001 iconG2 rewardG2 rewardG2 reward

    © Zyte Group Limited 2026
    XFacebookInstagramYouTubeLinkedInDiscord