cdpfleet Docs GitHub Dashboard

Cases / #38 · 2026-10-12 · Medium

Logging in through a TOTP two-factor prompt from a remote browser

Generate the authenticator code in your own code, type it into a remote browser, and save the login so the next session skips the second factor.

Chromium

Run on production on 2026-10-12: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

More and more logins ask for a one-time code from an authenticator app. An automated login can't wait for a phone, but it doesn't need one: the app only runs HMAC-SHA1 over the current 30-second window with a shared secret, which your code can do with the standard library of any language. The traps are timing — a code typed in the last seconds of its window may be checked in the next one — and logging in more often than you need to. We log into a public TOTP practice page from a cdpfleet browser.

What we used, and why

WhatWhy
authenticationtest.comA public practice login with e-mail, password and a TOTP code. The account, password and TOTP secret in the programs are the demo values the site itself publishes; for a real account, load the secret from an environment variable or a secret store.
TOTP in your own codeHMAC-SHA1 of the 30-second counter with the base32-decoded secret, truncated to 6 digits (RFC 6238). Standard library only, in all five languages: no TOTP package needed.
A fresh-window guardAfter the form has loaded and right before typing, the program checks how much of the current window is left; with fewer than 5 seconds it waits for the next window, then generates the code.
A new context per attemptEvery attempt starts without cookies, so one success doesn't leak into the next attempt.
context.storageState() → newContext({ storageState })Saves the signed-in cookies and restores them in a new session (a different browser, a different sessionId).

How it works

  1. Launch headless Chromium through your proxy. Check the site's clock — its Date header should fall between your clock just before and just after the request — and record the remote browser's clock for comparison.
  2. Log in with a wrong code, and with the codes from two windows ago, the previous window and the next window, each in a fresh context.
  3. Log in with the current code and save the context's storage state.
  4. End the session. Launch a new one and open the site with and without the saved state.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL
import { createHmac } from 'node:crypto';
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const SITE = 'https://authenticationtest.com';
// The practice site publishes its test account and TOTP secret on the challenge page.
const LOGIN = { email: '[email protected]', password: 'pa$$w0rd', secret: 'I65VU7K5ZQL7WB4E' };

// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps) from the base32 secret — what an authenticator app does.
function totp(secret, unixSeconds) {
  let bits = '';
  for (const c of secret.replace(/=+$/, '')) bits += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(c).toString(2).padStart(5, '0');
  const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
  const counter = Buffer.alloc(8);
  counter.writeBigUInt64BE(BigInt(Math.floor(unixSeconds / 30)));
  const h = createHmac('sha1', key).update(counter).digest();
  const o = h[h.length - 1] & 15;
  return String((h.readUInt32BE(o) & 0x7fffffff) % 1000000).padStart(6, '0');
}

// A code typed in the last seconds of its window can expire before the server checks it:
// wait for the next window when fewer than 5 s are left.
async function freshWindow() {
  const left = 30 - (Date.now() / 1000) % 30;
  if (left < 5) await new Promise((r) => setTimeout(r, left * 1000 + 200));
  return { seconds_left_in_window: Math.round(left * 10) / 10, waited_s: left < 5 ? Math.round(left * 10) / 10 : 0 };
}

async function launch() {
  const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
    method: 'POST',
    headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ proxy: process.env.PROXY_URL, headless: true }),
  });
  if (!res.ok) throw new Error(`launch ${res.status} ${await res.text()}`);
  const { sessionId, wsUrl } = await res.json();
  return { sessionId, browser: await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } }) };
}

// One login attempt in a fresh context (no cookies from earlier attempts). The code is made
// after the form has loaded, right before typing it: offset null types a wrong code, otherwise
// the code for now + offset seconds.
async function attempt(browser, label, offset) {
  const context = await browser.newContext();
  const page = await context.newPage();
  await page.goto(`${SITE}/totpChallenge/`, { timeout: 60000 });
  const window = await freshWindow();
  const code = offset === null ? '000000' : totp(LOGIN.secret, Date.now() / 1000 + offset);
  const t = Date.now();
  await page.fill('#email', LOGIN.email);
  await page.fill('#password', LOGIN.password);
  await page.fill('#totpmfa', code);
  await Promise.all([page.waitForURL(/\/login(Success|Fail)\//, { timeout: 60000 }), page.click('input[type=submit]')]);
  const landed = new URL(page.url()).pathname;
  return { context, result: { attempt: label, offset_s: offset, ...window, landed, accepted: landed === '/loginSuccess/', ms: Date.now() - t } };
}

const signedIn = async (page) => (await page.locator('text=Sign Out').count()) > 0;

const out = { site: `${SITE}/totpChallenge/`, session_ids: {}, clocks: {}, attempts: [], storage_state: null, second_session: null };
let state;
const first = await launch();
out.session_ids.first = first.sessionId;
try {
  // What matters is your clock against the server's. The site's Date header (from its CDN
  // edge, 1 s resolution) should fall between our clock just before and just after the
  // request. The remote browser's clock is recorded for comparison only.
  const page = await first.browser.newPage();
  const t0 = Date.now() / 1000;
  const resp = await page.goto(`${SITE}/totpChallenge/`, { timeout: 60000 });
  const t1 = Date.now() / 1000;
  const server = Date.parse(resp.headers().date) / 1000;
  out.clocks.server_date_within_request = server >= Math.floor(t0) - 1 && server <= t1 + 1;
  out.clocks.request_s = Math.round((t1 - t0) * 10) / 10;
  out.clocks.browser_minus_local_s = Math.round((await page.evaluate(() => Date.now()) - Date.now()) / 100) / 10;
  await page.close();

  for (const [label, offset] of [
    ['wrong code', null],
    ['code from 2 windows ago (-60 s)', -60],
    ['code from the previous window (-30 s)', -30],
    ['code from the next window (+30 s)', 30],
  ]) {
    const { context, result } = await attempt(first.browser, label, offset);
    out.attempts.push(result);
    await context.close();
  }
  const { context, result } = await attempt(first.browser, 'current code', 0);
  out.attempts.push(result);
  state = await context.storageState();
  out.storage_state = { cookies: state.cookies.length, session_cookies: state.cookies.filter((c) => c.expires === -1).length };
} finally {
  await first.browser.close();
}

// A new session (another browser): without the saved state, then with it.
const second = await launch();
out.session_ids.second = second.sessionId;
try {
  out.second_session = {};
  for (const [label, options] of [['without_state', {}], ['with_state', { storageState: state }]]) {
    const context = await second.browser.newContext(options);
    const page = await context.newPage();
    await page.goto(`${SITE}/`, { timeout: 60000 });
    out.second_session[label] = { signed_in: await signedIn(page) };
    await context.close();
  }
} finally {
  await second.browser.close();
}
console.log(JSON.stringify(out, null, 2));

What we got

Login attemptWindow left when typedLanded onAcceptedSubmit → result (ms)
wrong code11.4 s/loginFail/no947
code from 2 windows ago (-60 s)8.4 s/loginFail/no1229
code from the previous window (-30 s)4.5 s → waited for the next/loginSuccess/yes1105
code from the next window (+30 s)21.1 s/loginSuccess/yes1543
current code15.3 s/loginSuccess/yes2544
new session, without saved state/ (signed out)no
new session, with saved state/ (signed in)yes

From the Node.js run on 2026-10-12. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
{
  "site": "https://authenticationtest.com/totpChallenge/",
  "session_ids": {
    "first": "02aef96a-94bc-47b1-98a2-18c6e94e410a",
    "second": "c6bb701f-a165-4421-ad0d-4375eeb12d39"
  },
  "clocks": {
    "server_date_within_request": true,
    "request_s": 3.1,
    "browser_minus_local_s": 0
  },
  "attempts": [
    {
      "attempt": "wrong code",
      "offset_s": null,
      "seconds_left_in_window": 11.4,
      "waited_s": 0,
      "landed": "/loginFail/",
      "accepted": false,
      "ms": 947
    },
    {
      "attempt": "code from 2 windows ago (-60 s)",
      "offset_s": -60,
      "seconds_left_in_window": 8.4,
      "waited_s": 0,
      "landed": "/loginFail/",
      "accepted": false,
      "ms": 1229
    },
    {
      "attempt": "code from the previous window (-30 s)",
      "offset_s": -30,
      "seconds_left_in_window": 4.5,
      "waited_s": 4.5,
      "landed": "/loginSuccess/",
      "accepted": true,
      "ms": 1105
    },
    {
      "attempt": "code from the next window (+30 s)",
      "offset_s": 30,
      "seconds_left_in_window": 21.1,
      "waited_s": 0,
      "landed": "/loginSuccess/",
      "accepted": true,
      "ms": 1543
    },
    {
      "attempt": "current code",
      "offset_s": 0,
      "seconds_left_in_window": 15.3,
      "waited_s": 0,
      "landed": "/loginSuccess/",
      "accepted": true,
      "ms": 2544
    }
  ],
  "storage_state": {
    "cookies": 1,
    "session_cookies": 1
  },
  "second_session": {
    "without_state": {
      "signed_in": false
    },
    "with_state": {
      "signed_in": true
    }
  }
}

Takeaways