Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Cloudflare Turnstile auto-passes in standalone Playwright script but requires manual interaction in production (Django + Celery) #91

Unanswered
madnancp asked this question in Q&A
Discussion options

I've been stuck on this for a while and wanted to document it properly in case others run into the same thing :)

My Setup
I'm using CloakBrowser (persistent context) with login to a portal that uses Cloudflare Turnstile. The backend is Django with Celery workers handling the browser automation tasks.

What works

A standalone asyncio script that launches a persistent browser context, opens a new page, and navigates to the portal. Turnstile auto-passes every time, login succeeds, no manual interaction needed. Works with both headless=True and headless=False.

 browser = await launch_persistent_context_async(
 headless=True,
 user_data_dir=".user/",
 )
 page = await browser.new_page()
 await page.goto("https://portal.com")
 await page.wait_for_timeout(4000)

What does not work

The same portal, same binary, same machine, same profile directory, inside a Celery task. Turnstile consistently shows the checkbox and waits for human interaction instead of auto-passing.
The browser session is managed through a registry that creates one BrowserContext per company and reuses it across tasks. Pages are created on demand per task.

class BrowserRegistry:
 _instances: Dict[int, BrowserSession] = {}
 @classmethod
 async def get(cls, company_id: int):
 if company_id not in cls._instances:
 session = BrowserSession(company_id)
 await session.start()
 cls._instances[company_id] = session
 return cls._instances[company_id]

The Celery task calls into this registry, gets a page, runs the login flow. Everything is structurally identical to the standalone script except the execution context.

What the logs show

On the very first task after a fresh Celery startup, no prior sessions, no reuse happening yet:

[SESSION] STARTING company=1 profile=.../profiles/company_1
[SESSION] READY startup=1.25s initial_pages=1
[PAGE] REUSING slot=0 url='about:blank'
[AUTH] NAVIGATING to base_url
# 30 seconds pass...
TimeoutError: wait_for_load_state("networkidle") timed out
[PAGE CLOSE] url='https://portal.com/account/login?returnUrl=...'

The page navigates to the portal, gets redirected to login, and then hangs. The timeout is because Turnstile is showing the checkbox and keeping the page in an active network state waiting for interaction. If you manually click the checkbox it passes and login works fine, so Turnstile is not hard-blocking, just not auto-passing.

I have tried

  • Not a headless detection issue, standalone works in both modes
  • Not a session reuse issue, fails on the very first task after fresh startup
  • Not an event loop issue, moved from asyncio.run() per task to a persistent loop
  • Not a profile/cookie issue, same profile directory used in both cases
  • Not a network issue, same machine, same connection

Has anyone seen Turnstile scoring behave differently based on how the browser process was spawned or what process context it runs in? Is there something CloakBrowser sets up differently when launched from an interactive terminal vs a background worker process that could affect how Turnstile evaluates the session?

Any insight into what signals Turnstile might be picking up differently between these two environments would be really helpful.

You must be logged in to vote

Replies: 1 comment 1 reply

Comment options

Thanks for the detailed write-up.

The most likely cause is accumulated CDP (Chrome DevTools Protocol) traffic in the long-lived Celery browser session.

Two things to change first:

  1. Replace page.wait_for_timeout() with await asyncio.sleep()wait_for_timeout() sends CDP commands that Turnstile can detect. asyncio.sleep() is invisible to the browser. Same for page.wait_for_load_state("networkidle") — it opens a CDP listener that monitors all network activity. Use asyncio.sleep() with a fixed delay instead.

  2. Create a fresh page instead of reusing the default about:blank — your logs show [PAGE] REUSING slot=0 url='about:blank'. Use await browser.new_page() and close the page when done. The default page from persistent context has been alive since browser launch, accumulating CDP state.

If Turnstile still shows the checkbox after those changes, use the keyboard approach instead of clicking it:

await asyncio.sleep(3) # let Turnstile widget initialize
await page.keyboard.press("Tab")
await asyncio.sleep(0.5)
await page.keyboard.press("Space")

This has been confirmed working by multiple users in Docker and headless environments (#53, #79). Keyboard events don't go through the CDP mouse dispatch path that Turnstile detects.

The core difference between your standalone script and Celery is that the standalone script launches, navigates immediately, and exits — minimal CDP footprint. The Celery worker keeps the browser alive across tasks, and the idle CDP connection generates background protocol traffic that degrades Turnstile's trust score.

Let us know if that helps.

You must be logged in to vote
1 reply
Comment options

I’ve switched from using launch_persistent_context to connecting via a CDP connection instead, and I’m now running the browser through a separate browser-pool microservice. It seems to be working better so far, though I’m still validating.

Thank you for the keyboard approach!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
Q&A
Labels
None yet

AltStyle によって変換されたページ (->オリジナル) /