Handling Playwright MFA: real TOTP, storageState, and OTP-polling workarounds — plus why some MFA can't be automated and a maintained API fixes it.

If you landed here you almost certainly have a Playwright script that logs in fine right up until the portal asks for a six-digit code, and now the whole flow hangs on a screen you can't script your way past. Playwright MFA handling is one of those problems that looks like a one-line fix and turns into a standing maintenance job. This piece is a real how-to first: it walks through the workarounds that actually work — TOTP generation, session reuse, OTP polling — and is honest about the one class of MFA you simply cannot automate. Then it steps back to why MFA is painful in a browser at all, and how solving it at a different layer makes the problem disappear.
There are genuine, working Playwright MFA workarounds: generate TOTP codes in-script from the shared secret, reuse an authenticated storageState to skip re-login, and poll a mailbox or SMS API for email/SMS OTPs. But every one of them is maintenance-heavy — they break when the login page, MFA provider, or method changes — and push-approval and hardware-key MFA can't be automated at all. If MFA is a recurring cost rather than a one-off test setup, the durable fix is to authenticate once at the network layer behind a maintained API instead of re-solving login in the browser.
| Playwright MFA workarounds | Supergood | |
|---|---|---|
| TOTP support | Yes — generate the code in-script from the shared secret (otplib / pyotp) | Handled server-side; not your code |
| SMS / email OTP | Possible — poll a mailbox or SMS API/webhook for the code | Handled automatically — real service-account email + phone per agent |
| Push / hardware-key MFA | No — the hard wall; not automatable | Provisioned so the flow doesn't depend on a human tap |
| Who maintains it when the login changes | You do — new selectors, new provider quirks, new flow | Maintained for you; a maintenance agent ships fixes, usually before you notice |
| Latency per call | Seconds — browser boot, render, DOM settle, plus the code round-trip | Milliseconds — one network request after auth |
| Observability | You build it (screenshots, traces, retries) | Built in — full audit logs (request, response, timing, status) |
| Best for | Your own app in CI, low-volume or one-off, free & open source | Production, high-volume agent access to third-party portals with no public API |
Here is the honest tour. Each of these is legitimate and each has a boundary.
If the portal uses an authenticator-app style factor (Google Authenticator, Authy, Okta Verify in TOTP mode), the six digits are just a function of a shared secret and the current time (the TOTP algorithm, RFC 6238). If you can get that secret — usually the base32 string behind the "can't scan the QR code?" link during enrollment — you can compute the same code your phone would, no phone required.
In Node with otplib:
import { authenticator } from 'otplib';
const token = authenticator.generate(process.env.TOTP_SECRET); // 6-digit code
await page.fill('#username', process.env.PORTAL_USER);
await page.fill('#password', process.env.PORTAL_PASS);
await page.click('button[type=submit]');
await page.fill('#otp', token);
await page.click('#verify');The Python equivalent is pyotp.TOTP(secret).now(). What this solves: fully unattended login for TOTP factors. What it doesn't solve: you have to legitimately possess the secret (you can't derive it from a code), codes rotate every 30 seconds so a slow step can land you on an expired token, and it does nothing for SMS, email, push, or hardware keys.
The cheapest way to handle MFA is to not trigger it. Playwright can serialize cookies and local storage after a successful login and rehydrate them later, so subsequent runs skip the login form — and the MFA prompt — entirely.
// One-time (or periodic) setup: log in once, save the session
const context = await browser.newContext();
const page = await context.newPage();
// ... perform full login + MFA once ...
await context.storageState({ path: 'auth.json' });
// Every later run: start already authenticated
const context = await browser.newContext({ storageState: 'auth.json' });What this solves: it removes MFA from the hot path for as long as the session is valid, and it pairs well with a "trust this device" checkbox that suppresses re-prompts. What it doesn't solve: sessions expire — sometimes hourly — and when auth.json goes stale you are back at the full login-plus-MFA dance, now inside a scheduled refresh job you also have to maintain.
When the code is delivered out of band, you have to fetch it. For email OTP, poll an inbox over IMAP or a provider API (Gmail API, a transactional inbox like Mailosaur) and regex out the digits. For SMS OTP, use a programmable-SMS number (Twilio and similar) and read the code from the message log or a webhook.
// Illustrative: poll a mailbox until the code arrives, then fill it
const code = await pollForOtp({ since: Date.now(), timeoutMs: 60_000 });
await page.fill('#otp', code);
await page.click('#verify');What this solves: real end-to-end automation for SMS/email factors. What it doesn't solve: you now own a mailbox or a phone number, a polling loop, and parsing that breaks the day the provider rewords the message. This is the most brittle of the workarounds because you're scraping a human-readable message for a machine value.
Push approval ("tap to approve" in an authenticator app) and hardware keys (FIDO2/WebAuthn, YubiKey, passkeys) are designed specifically so that possession can't be faked by software. There is no in-script equivalent: a person has to tap the phone or touch the key. You can sometimes fall back to a weaker factor if the portal offers one, but if push or a security key is mandatory, unattended Playwright automation stops here. This is not a bug you can out-clever; it is the security property working as intended.
Assume you get (a)–(c) working. The work isn't done — it's begun. Each of these is coupled to specifics that move: the OTP field's selector, the provider's flow (Okta vs Duo vs Entra vs a homegrown form), the "trust this device" modal's markup, the wording of the OTP email, the session lifetime. Any one changing silently breaks the login, and the failure often surfaces downstream as "the agent didn't do the thing," not "MFA broke." A working Playwright MFA flow is a living dependency on someone else's login page.
Here's a realistic Playwright flow that logs in through username/password plus a TOTP factor, waits for the app to load, and dismisses the "trust this device" modal.
import { chromium } from 'playwright';
import { authenticator } from 'otplib';
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://portal.example.com/login');
await page.fill('#username', process.env.PORTAL_USER);
await page.fill('#password', process.env.PORTAL_PASS);
await page.click('button[type=submit]');
// MFA step: portal now shows a 6-digit code prompt
await page.waitForSelector('#otp', { timeout: 15_000 });
const token = authenticator.generate(process.env.TOTP_SECRET);
await page.fill('#otp', token);
await page.click('#verify');
// "Trust this device?" modal is optional and its markup drifts
const trust = page.locator('text=Trust this device');
if (await trust.isVisible().catch(() => false)) {
await page.click('#dont-ask-again');
await page.click('#confirm-trust');
}
// Only now are we actually logged in
await page.waitForSelector('#dashboard', { timeout: 15_000 });
// ... navigate to the page, find the selector, do the real work ...
await context.storageState({ path: 'auth.json' }); // so next run can skip this
await browser.close();Roughly forty lines, three timeouts, two selectors that can drift, one optional modal, and a token that expires in thirty seconds — before you've done any actual work. Now the equivalent with MFA handled server-side:
# Authenticate once — Supergood handles login, MFA (service-account email + phone), and CAPTCHAs
curl -X POST https://api.supergood.ai/v1/yardi/auth \
-H "Authorization: Bearer $SUPERGOOD_KEY" -d '{"account": "acme-co"}'
# → { "session": "sg_sess_9f2..." } # no OTP handling in your code
# Then call the backend action directly
curl -X POST https://api.supergood.ai/v1/yardi/charges \
-H "Authorization: Bearer $SUPERGOOD_KEY" \
-d '{"session": "sg_sess_9f2...", "tenant_id": "t_123", "amount": 2150.00}'There is no #otp, no TOTP generation, no modal, no auth.json refresh job, because the MFA lives one layer down where your code never touches it.
The uncomfortable truth: MFA is not really a browser problem. It's an authentication problem you inherited by choosing to log in like a human. A browser is a rendering surface built for eyes and hands, and every MFA method is a different obstacle placed on that surface — a code field here, a push prompt there, a hardware-key ceremony somewhere else. Automating at the UI layer means every provider, every method, and every login redesign is a new special case you have to detect, script, and keep alive.
That coupling is expensive in a way that compounds. Computer-using agents already spend most of their effort on overhead rather than the task — in one study, tasks a human finishes in about two minutes take agents 20+ minutes, and 75–94% of total time is burned on planning and reflection rather than the work itself (arXiv:2506.16042). Bolt a fragile MFA step onto that and you add the worst failure mode there is: not a clean crash, but a half-login. The same body of research documents "trajectory degradation," where an agent silently drops a step or submits the wrong record and still reports success (arXiv:2511.17131). A login that half-completes — session established but MFA not fully confirmed, or a stale auth.json that loads a logged-out shell — is exactly this: it fails quietly and downstream, which is far worse than failing loudly at the login screen. A flow that hangs on the OTP prompt at least tells you where it broke.
This is the deeper argument behind the antibrowser idea: the browser is the wrong place to be running production automation, and MFA is just where that shows up first. Most enterprise software has a real backend behind the login form — an unofficial API the frontend is already calling. Talk to that instead and the MFA obstacle course is a one-time auth step, not a per-call gauntlet.
Supergood generates and maintains APIs and agent tools for enterprise software that has no public API — roughly 90% of enterprise portals. You record a workflow once; it captures the underlying network calls the portal already makes and generates deployed REST endpoints and MCP tools. No SDK, no selectors, no headless browser.
MFA fits into that model as an auth concern, handled once, server-side:
curl above). Milliseconds per call, not a browser session per call.Delivery is REST + OpenAPI + MCP (npx supergood-mcp init) and works with Claude, OpenAI, LangChain, n8n, Zapier, or any REST client. It's SOC 2 Type II and HIPAA compliant with an on-prem option, which matters when the "MFA" you're automating past is guarding regulated data. See the docs, or a concrete portal example like the Yardi API. This structural approach is the same reason agentic AI built on maintained APIs is more reliable than agents driving a browser: fewer moving parts on the hot path.
Being fair: the workarounds above are the correct answer in several real cases, and Supergood is not one of them.
storageState reuse plus a manual MFA every so often is entirely reasonable — no vendor required.The line is ownership and volume. If you control the MFA provider, or you're logging in rarely, script it yourself. If you're logging into someone else's portal, at volume, in production, re-solving MFA on every deploy of their login page is a job that never ends.
How do I handle MFA in Playwright? Pick the workaround that matches the factor. For authenticator-app (TOTP) codes, generate the six digits in-script from the shared secret with otplib (Node) or pyotp (Python) and fill the field. To avoid the prompt entirely, save an authenticated session with storageState and reuse it. For SMS or email codes, poll a mailbox or SMS API for the code and fill it. Push and hardware-key MFA can't be automated.
Can Playwright enter a 2FA / OTP code? Yes — Playwright can fill an OTP field like any other input. The hard part isn't typing the code, it's obtaining it. That means TOTP generation from the shared secret, or polling an inbox / SMS API for a delivered code, and keeping that plumbing working as the provider changes.
How do I automate login for a portal that forces MFA, like Navan, Filevine, or Alfresco? When the portal is a third party you don't control, your options are: reuse a storageState session and refresh it periodically, or poll for the OTP if it's SMS/email based. Both work but are maintenance-heavy, and neither helps if the portal mandates push or a hardware key. For production, high-volume access to a portal like this, authenticating once at the network layer behind a maintained API removes the per-call MFA step entirely — that's the model Supergood uses.
Can you automate push-approval MFA? No. Push approval and hardware keys (FIDO2/WebAuthn, YubiKey, passkeys) are designed so software can't fake possession. If a weaker factor is offered you can sometimes fall back to it, but a mandatory push or security-key step requires a human tap and cannot be scripted.
Why does my Playwright MFA login keep breaking? Because it's coupled to details that move — the OTP field selector, the "trust this device" modal, the provider's flow, the wording of the OTP email, the session lifetime. Any of these can change silently, and the failure usually surfaces downstream as a broken task rather than an obvious login error. A maintained API absorbs those changes for you instead.
If MFA has become a recurring maintenance cost rather than a one-time test-setup detail, it's worth solving at the right layer. See how Supergood authenticates once and hands you a REST/MCP endpoint at supergood.ai — MFA included, and maintained.
MFA is not a browser problem; it's an authentication problem you inherited by choosing to log in like a human. Solve it once, at the right layer, and stop re-solving it every time the login page moves a button.