Contact
READING PROGRESS:0%
POST-MORTEM

Why device-trust 2FA trips on every single automated run, and why running headed is a fair price.

Alexander Graham Bell aerodynamic kite test apparatus (1903)

Alexander Graham Bell, aerodynamic test apparatus (1903). Mechanical rigs built to measure invisible drag against artificial wind currents.

SOURCE: Alexander Graham Bell, Public Domain Image ArchiveARCHIVE PLATE ↗

At 6:48 AM, my phone buzzed on the nightstand. A six-digit SMS two-factor authentication code from a vendor platform. Two minutes later, another buzz. By 7:15 AM, my inbox had eight security alert emails asking if I had just logged in from an unrecognized device in Ashburn, Virginia.

The culprit was a 40-line Playwright script designed to pull billing summaries and sync roster data.

The automation wasn’t getting blocked by Cloudflare or failing credentials. It had valid credentials stored in the system keychain. It wasn’t even failing bot protection. It was falling face-first into the most basic security gate on the modern internet: device trust.

The Myth of the reCAPTCHA Wall

When engineers start scripting web workflows, the folk wisdom says: “You can’t automate past modern CAPTCHA without sketchy proxy pools or residential anti-bot farms.”

That assumption was flat wrong.

The target SaaS was running invisible Google reCAPTCHA Enterprise on the login page. In testing, that invisible score waved every single scripted attempt through with a risk score near zero. The requests were coming from my regular residential IP block. The SSL handshake looked legitimate.

The tripwire wasn’t bot detection. It was device memory.

Enterprise platforms don’t challenge logins because they think you’re a Russian botnet. They challenge logins because your browser profile has zero history. A default Playwright context starts with an empty cache, clean cookies, an empty IndexedDB store, and a blank localStorage envelope. To the identity provider, that looks identical to a laptop purchased at Best Buy twenty minutes ago.

The Headless Fingerprint

The obvious first fix was wiring persistent context:

# Save state to disk across runs
context = browser.launch_persistent_context(
    user_data_dir="./browser-profile",
    headless=True
)

The plan: complete the SMS challenge by hand once, save the profile to ./browser-profile, and let the session cookies ride for the next 90 days.

It worked for exactly one run. On run number two, the SMS challenge returned.

Why? Look at the HTTP request header sent by default:

User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/128.0.6613.18 Safari/537.36

HeadlessChrome.

The browser was literally introducing itself as a headless robot. The site’s device fingerprinting script hashes together the User-Agent, WebGL vendor strings, canvas render output, and hardware concurrency count to generate a persistent device ID.

When you flip between headed mode during your manual setup and headless mode during scheduled execution, the device hash shifts. The server sees the same authentication cookies paired with an altered hardware fingerprint. In the eyes of the fraud engine, that looks like session hijacking. It locks the gate and fires the SMS flare.

Don’t Play Cat and Mouse: Just Run Headed

At this point, junior scrapers go down the rabbit hole of fingerprint spoofing libraries: injecting fake WebGL extensions, patching navigator.webdriver, and overriding navigator properties.

Don’t do it.

Fingerprint spoofing is an unwinnable arms race. Detection heuristics look for internal inconsistencies in the spoofed objects—like a WebGL renderer reporting an Apple M3 chip while the audio subsystem reports a Linux ALSA driver.

The durable, unvarnished solution is simpler: run the browser headed.

context = browser.launch_persistent_context(
    user_data_dir=os.path.expanduser("~/.config/automation-profiles/studio"),
    headless=False,
    args=["--window-position=-2400,-2400"] # Tuck it offscreen or let it flash
)

A small Chrome window flashing on your secondary display for four seconds at 6:50 AM is a tiny price to pay for 100% reliable execution. You aren’t sneaking around security controls; you are using the vendor’s documented “remember this trusted device” feature exactly as intended.

Build the Pager, Not the Solver

What happens when session cookies eventually expire after 90 days?

The worst architecture is one that pretends it can solve out-of-band MFA autonomously. An automation script should never intercept SMS or forward one-time passcodes without human eyes on the glass.

Our runner uses a clean boundary condition:

if page.locator("text='Enter Verification Code'").is_visible():
    logger.error("Device trust expired. Human intervention required.")
    sys.exit(42) # Specific exit code caught by the orchestrator

When exit code 42 triggers, the scheduler doesn’t retry in an infinite loop. It fires a Slack ping to the admin channel: “Session token expired for vendor sync. Run with --interactive to refresh.”

I spend thirty seconds typing an SMS code into a terminal once a quarter. The rest of the year, the sync runs undisturbed.

Honest Caveats

  • Display Server Requirements: Running headless=False works cleanly on a local macOS workstation or a machine with a connected display. On a headless Linux cloud server (like an AWS EC2 box without an X11 server or virtual framebuffer like Xvfb), a headed browser will fail immediately unless you configure a virtual display.
  • Session Lifespans Are Unpredictable: Vendors can invalidate session tokens at any time during security incidents, password rotations, or platform updates. If your workflow cannot tolerate waiting a few hours for a human to re-authenticate, pure browser automation is the wrong tool; you need an official enterprise API with service-account credentials.

Stop fighting the security model. Work with the grain of the platform.

STAY IN THE LOOP