---
title: How to monitor a login with 2FA (TOTP) in Playwright
description: Your login is the one page every customer must get through, and two-factor authentication is the part a monitor usually skips. It doesn't have to: a TOTP code is just arithmetic on a shared secret and the clock. Here's a Playwright test that signs in with a real one-time code, the three ways it goes wrong, and how to run it every few minutes without leaking the secret.
canonical: https://watchfor.io/blog/monitor-a-2fa-login-with-playwright
---

# How to monitor a login with 2FA (TOTP) in Playwright

2026-09-29 · WatchFor Team · Monitoring · synthetic-monitoring, playwright, security, authentication

Your login is the one page every customer must get through, and two-factor authentication is the part a monitor usually skips. It doesn't have to: a TOTP code is just arithmetic on a shared secret and the clock. Here's a Playwright test that signs in with a real one-time code, the three ways it goes wrong, and how to run it every few minutes without leaking the secret.

If your sign-in breaks, nothing else you monitor matters: every customer is
locked out, and your uptime check still says 200 because the login *page*
loads fine. The only honest test is to sign in, the way a person does — and
for most B2B products today that means a password **and** a code from an
authenticator app.

That second step is where most login monitors give up. They test a special
account with 2FA switched off, which proves everything except the part that
breaks most interestingly: the code step, the session after it, the redirect
at the end.

You don't have to give up. A time-based one-time password (TOTP) is not a
secret delivered to a phone. It is arithmetic that anyone holding the shared
secret can do, and a test can hold the secret as well as an app can.

## What a TOTP code actually is

When you enrol in 2FA, the site shows a QR code. Inside it is a **base32
secret**, something like `I65VU7K5ZQL7WB4E`, plus a few parameters. Your
authenticator app and the server both keep that secret. Every 30 seconds each
of them computes an HMAC of the secret and the current time step, and takes six
digits from it. That's RFC 6238, and it's why the app works in flight mode:
nothing is sent to the phone.

So a script can generate exactly the code the app shows, as long as it has:

1. the base32 secret (most sites offer it under "can't scan the code?" next to
   the QR), and
2. a correct clock.

In Node, the [otpauth](https://github.com/hectorm/otpauth) library does the
arithmetic in one line.

## The test

This runs as-is against a public practice site, authenticationtest.com, which
publishes a test account *and* its TOTP secret for exactly this purpose. Copy
it, run it, then swap in your own site:

```ts

// Practice-site values. On your own site, read these from process.env and
// store the password and the TOTP secret as secrets.
const username = 'totp@authenticationtest.com';
const password = 'pa$$w0rd';
const totpSecret = 'I65VU7K5ZQL7WB4E';

test('login with 2FA', async ({ page }) => {
  const totp = new TOTP({ secret: totpSecret });

  await test.step('Open the login page', async () => {
    await page.goto('https://authenticationtest.com/totpChallenge/');
  });

  await test.step('Enter the password and a one-time code', async () => {
    await page.getByLabel('E-Mail Address').fill(username);
    await page.getByLabel('Password').fill(password);
    // A code about to expire can be stale by the time the form is sent.
    if (totp.remaining() < 5_000) await page.waitForTimeout(totp.remaining() + 500);
    await page.getByLabel(/MFA code/i).fill(totp.generate());
    await page.getByRole('button', { name: 'Log In' }).click();
  });

  await test.step('Signed in', async () => {
    await expect(page.getByText(/login success/i).first()).toBeVisible();
  });
});
```

```bash
npm install -D @playwright/test otpauth
npx playwright install chromium
npx playwright test
```

Three `test.step`s, each named the way a person would describe it. When it
fails at 3 a.m., "Failed at *Enter the password and a one-time code*" tells
you more than a stack trace.

## Three ways it goes wrong (and the fix for each)

### 1. The code expires between generating and submitting

A code is valid for its 30-second window. Generate it with one second left, and
by the time the browser fills the field, clicks, and the server checks it, the
window has moved on. Many servers accept the previous code for a few seconds,
but you shouldn't depend on it.

**Fix:** if the current window has less than five seconds left, wait for the
next one before generating. That's the `totp.remaining()` line above. It costs
at most five seconds, and only one run in six waits at all.

### 2. The same code is used twice

Good 2FA implementations refuse a code that has already been used, to stop
replay attacks. A monitor that retries a failed test — or two checks that sign
in to the same account from two locations at the same moment — can land in the
same window and be refused for doing nothing wrong.

**Fix:** give each check its own test account, and keep retries apart from the
first attempt (the `remaining()` guard helps here too). If the site shows
"code already used", that is your 2FA working, not failing.

### 3. The clock is wrong

TOTP is only as good as the clock on both sides. A container or CI runner that
has drifted by a minute produces codes that are always one or two windows off,
and the failure looks exactly like a wrong secret.

**Fix:** run where the clock is synchronised (any machine with NTP), and when
every single run fails at the code, check the clock before the secret.

## Things a script can't do

- **SMS and email codes.** A browser can't read your phone. Give the test
  account an authenticator (TOTP) factor instead; most sites allow several
  factors per account.
- **Push approvals and passkeys** need a device and a person. Test the rest of
  the flow on an account with TOTP.
- **CAPTCHAs** exist to stop exactly this. Allow your monitor's user agent for
  the test account, or monitor a staging host without the CAPTCHA.

## Keep the secret a secret

The TOTP secret is the second factor. Anyone who has it and the password *is*
your test account. So:

- Use a **dedicated test account** with the least access you can give it.
  Never enrol a real person's account in a monitor.
- Keep the secret and the password out of the script. Read them from
  `process.env` and store them in a secret store.
- Make sure they don't leak through the side doors: test reports, trace files,
  screenshots of a settings page, a `console.log` left in while debugging.

## Running it every few minutes

A test on your laptop proves the login worked once. A monitor proves it keeps
working. In WatchFor this script is the **Login with 2FA** example of a
[browser check](/docs/monitors/browser-checks): it passes on the practice site
on its first test run, **Use my site** turns the three values into
`process.env.LOGIN_USERNAME`, `LOGIN_PASSWORD` and `TOTP_SECRET`, and you store
the last two as secrets. From then on:

- Secrets are masked in logs, errors, step titles, trace text and screenshots
  of every scheduled run, not only in the editor.
- Each run gets a fresh, sandboxed browser, so every run really signs in.
- A failed test is retried once in the same run, and a failure is confirmed by
  a second run about a minute later before anyone is paged. A flaky moment
  never wakes you up; a broken login does.
- When it does fail, you see which step, what the page showed, and the page
  itself, next to the last passing run with its step timings.

The [recipes](/docs/monitors/browser-checks/recipes#totp) have the same pattern
for your own login form, and the
[troubleshooting guide](/docs/monitors/browser-checks/troubleshooting) covers
the errors you'll meet on the way.

---

Canonical page: https://watchfor.io/blog/monitor-a-2fa-login-with-playwright · All articles: https://watchfor.io/blog/all.md · Site guide: https://watchfor.io/llms.txt
