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.
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
| What | Why |
|---|---|
| authenticationtest.com | A 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 code | HMAC-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 guard | After 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 attempt | Every 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
- Launch headless Chromium through your proxy. Check the site's clock — its
Dateheader should fall between your clock just before and just after the request — and record the remote browser's clock for comparison. - 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.
- Log in with the current code and save the context's storage state.
- 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:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// 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));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL
import base64
import hashlib
import hmac
import json
import os
import re
import struct
import time
from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
SITE = "https://authenticationtest.com"
# The practice site publishes its test account and TOTP secret on the challenge page.
LOGIN = {"email": "[email protected]", "password": "pa$$w0rd", "secret": "I65VU7K5ZQL7WB4E"}
def totp(secret, unix_seconds):
"""RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps) from the base32 secret — what an authenticator app does."""
key = base64.b32decode(secret + "=" * (-len(secret) % 8))
h = hmac.new(key, struct.pack(">Q", int(unix_seconds // 30)), hashlib.sha1).digest()
o = h[-1] & 15
return "%06d" % ((struct.unpack(">I", h[o:o + 4])[0] & 0x7FFFFFFF) % 1000000)
def fresh_window():
"""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."""
left = 30 - time.time() % 30
if left < 5:
time.sleep(left + 0.2)
return {"seconds_left_in_window": round(left, 1), "waited_s": round(left, 1) if left < 5 else 0}
def launch(p):
res = requests.post("https://starter.cdpfleet.com/chromium/session",
headers={"x-api-key": KEY},
json={"proxy": os.environ["PROXY_URL"], "headless": True}, timeout=60)
if not res.ok:
raise SystemExit(f"launch {res.status_code} {res.text}")
session = res.json()
return session["sessionId"], p.chromium.connect(session["wsUrl"], headers={"x-api-key": KEY})
def attempt(browser, label, offset):
"""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 None types a wrong code, otherwise
the code for now + offset seconds."""
context = browser.new_context()
page = context.new_page()
page.goto(f"{SITE}/totpChallenge/", timeout=60000)
window = fresh_window()
code = "000000" if offset is None else totp(LOGIN["secret"], time.time() + offset)
t = time.time()
page.fill("#email", LOGIN["email"])
page.fill("#password", LOGIN["password"])
page.fill("#totpmfa", code)
page.click("input[type=submit]")
page.wait_for_url(re.compile(r"/login(Success|Fail)/"), timeout=60000)
landed = urlparse(page.url).path
return context, {"attempt": label, "offset_s": offset, **window, "landed": landed,
"accepted": landed == "/loginSuccess/", "ms": round((time.time() - t) * 1000)}
out = {"site": f"{SITE}/totpChallenge/", "session_ids": {}, "clocks": {}, "attempts": [], "storage_state": None, "second_session": None}
with sync_playwright() as p:
out["session_ids"]["first"], browser = launch(p)
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.
page = browser.new_page()
t0 = time.time()
resp = page.goto(f"{SITE}/totpChallenge/", timeout=60000)
t1 = time.time()
server = parsedate_to_datetime(resp.headers["date"]).timestamp()
out["clocks"]["server_date_within_request"] = int(t0) - 1 <= server <= t1 + 1
out["clocks"]["request_s"] = round(t1 - t0, 1)
out["clocks"]["browser_minus_local_s"] = round(page.evaluate("Date.now()") / 1000 - time.time(), 1) or 0.0
page.close()
for label, offset in [("wrong code", None), ("code from 2 windows ago (-60 s)", -60),
("code from the previous window (-30 s)", -30), ("code from the next window (+30 s)", 30)]:
context, result = attempt(browser, label, offset)
out["attempts"].append(result)
context.close()
context, result = attempt(browser, "current code", 0)
out["attempts"].append(result)
state = context.storage_state()
out["storage_state"] = {"cookies": len(state["cookies"]), "session_cookies": len([c for c in state["cookies"] if c["expires"] == -1])}
finally:
browser.close()
# A new session (another browser): without the saved state, then with it.
out["session_ids"]["second"], second = launch(p)
try:
out["second_session"] = {}
for label, options in [("without_state", {}), ("with_state", {"storage_state": state})]:
context = second.new_context(**options)
page = context.new_page()
page.goto(f"{SITE}/", timeout=60000)
out["second_session"][label] = {"signed_in": page.locator("text=Sign Out").count() > 0}
context.close()
finally:
second.close()
print(json.dumps(out, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1. env: CDPFLEET_API_KEY, PROXY_URL
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.nio.ByteBuffer;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final String SITE = "https://authenticationtest.com";
// The practice site publishes its test account and TOTP secret on the challenge page.
static final String 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.
static String totp(String secret, double unixSeconds) throws Exception {
String alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
ByteBuffer key = ByteBuffer.allocate(secret.length() * 5 / 8);
int buffer = 0, bits = 0;
for (char c : secret.replace("=", "").toCharArray()) {
buffer = (buffer << 5) | alphabet.indexOf(c);
bits += 5;
if (bits >= 8) { key.put((byte) (buffer >> (bits - 8))); bits -= 8; }
}
Mac mac = Mac.getInstance("HmacSHA1");
mac.init(new SecretKeySpec(key.array(), "HmacSHA1"));
byte[] h = mac.doFinal(ByteBuffer.allocate(8).putLong((long) Math.floor(unixSeconds / 30)).array());
int o = h[h.length - 1] & 15;
int value = (ByteBuffer.wrap(h, o, 4).getInt() & 0x7fffffff) % 1000000;
return String.format("%06d", value);
}
static double now() { return System.currentTimeMillis() / 1000.0; }
static double round1(double v) { return Math.round(v * 10) / 10.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.
static JsonObject freshWindow() throws InterruptedException {
double left = 30 - now() % 30;
if (left < 5) Thread.sleep((long) (left * 1000) + 200);
JsonObject r = new JsonObject();
r.addProperty("seconds_left_in_window", round1(left));
r.addProperty("waited_s", left < 5 ? round1(left) : 0);
return r;
}
record Session(String id, Browser browser) {}
static Session launch(Playwright playwright) throws Exception {
String body = "{\"proxy\": " + new Gson().toJson(System.getenv("PROXY_URL")) + ", \"headless\": true}";
HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/chromium/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) throw new RuntimeException("launch: " + res.statusCode() + " " + res.body());
JsonObject session = JsonParser.parseString(res.body()).getAsJsonObject();
Browser browser = playwright.chromium().connect(session.get("wsUrl").getAsString(),
new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
return new Session(session.get("sessionId").getAsString(), browser);
}
record Attempt(BrowserContext context, JsonObject result) {}
// 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.
static Attempt attempt(Browser browser, String label, Integer offset) throws Exception {
BrowserContext context = browser.newContext();
Page page = context.newPage();
page.navigate(SITE + "/totpChallenge/", new Page.NavigateOptions().setTimeout(60000));
JsonObject window = freshWindow();
String code = offset == null ? "000000" : totp(SECRET, now() + offset);
long t = System.currentTimeMillis();
page.fill("#email", EMAIL);
page.fill("#password", PASSWORD);
page.fill("#totpmfa", code);
page.click("input[type=submit]");
page.waitForURL(Pattern.compile("/login(Success|Fail)/"), new Page.WaitForURLOptions().setTimeout(60000));
String landed = URI.create(page.url()).getPath();
JsonObject r = new JsonObject();
r.addProperty("attempt", label);
if (offset == null) r.add("offset_s", JsonNull.INSTANCE); else r.addProperty("offset_s", offset);
r.add("seconds_left_in_window", window.get("seconds_left_in_window"));
r.add("waited_s", window.get("waited_s"));
r.addProperty("landed", landed);
r.addProperty("accepted", landed.equals("/loginSuccess/"));
r.addProperty("ms", System.currentTimeMillis() - t);
return new Attempt(context, r);
}
public static void main(String[] args) throws Exception {
JsonObject out = new JsonObject();
out.addProperty("site", SITE + "/totpChallenge/");
JsonObject sessionIds = new JsonObject();
out.add("session_ids", sessionIds);
JsonObject clocks = new JsonObject();
JsonArray attempts = new JsonArray();
String state;
try (Playwright playwright = Playwright.create()) {
Session first = launch(playwright);
sessionIds.addProperty("first", first.id());
Browser browser = first.browser();
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.
Page page = browser.newPage();
double t0 = now();
Response resp = page.navigate(SITE + "/totpChallenge/", new Page.NavigateOptions().setTimeout(60000));
double t1 = now();
long server = ZonedDateTime.parse(resp.headers().get("date"), DateTimeFormatter.RFC_1123_DATE_TIME).toEpochSecond();
clocks.addProperty("server_date_within_request", server >= Math.floor(t0) - 1 && server <= t1 + 1);
clocks.addProperty("request_s", round1(t1 - t0));
clocks.addProperty("browser_minus_local_s", round1(((Number) page.evaluate("Date.now()")).doubleValue() / 1000 - now()));
page.close();
out.add("clocks", clocks);
out.add("attempts", attempts);
Object[][] tries = {
{ "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 },
};
for (Object[] tr : tries) {
Attempt a = attempt(browser, (String) tr[0], (Integer) tr[1]);
attempts.add(a.result());
a.context().close();
}
Attempt current = attempt(browser, "current code", 0);
attempts.add(current.result());
state = current.context().storageState();
JsonArray cookies = JsonParser.parseString(state).getAsJsonObject().getAsJsonArray("cookies");
int session = 0;
for (JsonElement c : cookies) if (c.getAsJsonObject().get("expires").getAsDouble() == -1) session++;
JsonObject saved = new JsonObject();
saved.addProperty("cookies", cookies.size());
saved.addProperty("session_cookies", session);
out.add("storage_state", saved);
} finally {
browser.close();
}
// A new session (another browser): without the saved state, then with it.
Session secondLaunch = launch(playwright);
sessionIds.addProperty("second", secondLaunch.id());
Browser second = secondLaunch.browser();
try {
JsonObject secondSession = new JsonObject();
Map<String, Browser.NewContextOptions> setups = new LinkedHashMap<>();
setups.put("without_state", new Browser.NewContextOptions());
setups.put("with_state", new Browser.NewContextOptions().setStorageState(state));
for (Map.Entry<String, Browser.NewContextOptions> setup : setups.entrySet()) {
BrowserContext context = second.newContext(setup.getValue());
Page page = context.newPage();
page.navigate(SITE + "/", new Page.NavigateOptions().setTimeout(60000));
JsonObject r = new JsonObject();
r.addProperty("signed_in", page.locator("text=Sign Out").count() > 0);
secondSession.add(setup.getKey(), r);
context.close();
}
out.add("second_session", secondSession);
} finally {
second.close();
}
}
System.out.println(new GsonBuilder().serializeNulls().setPrettyPrinting().disableHtmlEscaping().create().toJson(out));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL
using System.Buffers.Binary;
using System.Globalization;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Playwright;
const string Site = "https://authenticationtest.com";
// The practice site publishes its test account and TOTP secret on the challenge page.
const string Email = "[email protected]", Password = "pa$$w0rd", Secret = "I65VU7K5ZQL7WB4E";
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
var output = new JsonObject { ["site"] = $"{Site}/totpChallenge/", ["session_ids"] = new JsonObject() };
string state;
var (firstId, browser) = await Launch();
output["session_ids"]!["first"] = firstId;
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.
var page = await browser.NewPageAsync();
var t0 = Now();
var resp = await page.GotoAsync($"{Site}/totpChallenge/", new() { Timeout = 60000 });
var t1 = Now();
var server = DateTimeOffset.ParseExact(resp!.Headers["date"], "r", CultureInfo.InvariantCulture).ToUnixTimeSeconds();
output["clocks"] = new JsonObject
{
["server_date_within_request"] = server >= Math.Floor(t0) - 1 && server <= t1 + 1,
["request_s"] = Math.Round(t1 - t0, 1),
["browser_minus_local_s"] = Math.Round(await page.EvaluateAsync<double>("Date.now()") / 1000 - Now(), 1),
};
await page.CloseAsync();
var attempts = new JsonArray();
output["attempts"] = attempts;
foreach (var (label, offset) in new (string, int?)[] {
("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) })
{
var a = await Attempt(browser, label, offset);
attempts.Add(a.Result);
await a.Context.CloseAsync();
}
var current = await Attempt(browser, "current code", 0);
attempts.Add(current.Result);
state = await current.Context.StorageStateAsync();
var cookies = JsonNode.Parse(state)!["cookies"]!.AsArray();
output["storage_state"] = new JsonObject
{
["cookies"] = cookies.Count,
["session_cookies"] = cookies.Count(c => c!["expires"]!.GetValue<double>() == -1),
};
}
finally
{
await browser.CloseAsync();
}
// A new session (another browser): without the saved state, then with it.
var (secondId, second) = await Launch();
output["session_ids"]!["second"] = secondId;
try
{
var secondSession = new JsonObject();
foreach (var (label, options) in new (string, BrowserNewContextOptions)[] { ("without_state", new()), ("with_state", new() { StorageState = state }) })
{
var context = await second.NewContextAsync(options);
var page = await context.NewPageAsync();
await page.GotoAsync($"{Site}/", new() { Timeout = 60000 });
secondSession[label] = new JsonObject { ["signed_in"] = await page.Locator("text=Sign Out").CountAsync() > 0 };
await context.CloseAsync();
}
output["second_session"] = secondSession;
}
finally
{
await second.CloseAsync();
}
Console.WriteLine(output.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
async Task<(string Id, IBrowser Browser)> Launch()
{
var res = await http.PostAsJsonAsync("https://starter.cdpfleet.com/chromium/session",
new { proxy = Environment.GetEnvironmentVariable("PROXY_URL"), headless = true });
if (!res.IsSuccessStatusCode) throw new Exception($"launch: {(int)res.StatusCode} {await res.Content.ReadAsStringAsync()}");
var session = await res.Content.ReadFromJsonAsync<JsonElement>();
var b = await playwright.Chromium.ConnectAsync(session.GetProperty("wsUrl").GetString()!, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
return (session.GetProperty("sessionId").GetString()!, b);
}
// 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.
static async Task<(IBrowserContext Context, JsonObject Result)> Attempt(IBrowser browser, string label, int? offset)
{
var context = await browser.NewContextAsync();
var page = await context.NewPageAsync();
await page.GotoAsync($"{Site}/totpChallenge/", new() { Timeout = 60000 });
var window = await FreshWindow();
var code = offset is null ? "000000" : Totp(Secret, Now() + offset.Value);
var t = DateTime.UtcNow;
await page.FillAsync("#email", Email);
await page.FillAsync("#password", Password);
await page.FillAsync("#totpmfa", code);
await page.ClickAsync("input[type=submit]");
await page.WaitForURLAsync(new Regex("/login(Success|Fail)/"), new() { Timeout = 60000 });
var landed = new Uri(page.Url).AbsolutePath;
return (context, new JsonObject
{
["attempt"] = label,
["offset_s"] = offset,
["seconds_left_in_window"] = window.SecondsLeft,
["waited_s"] = window.WaitedS,
["landed"] = landed,
["accepted"] = landed == "/loginSuccess/",
["ms"] = (long)(DateTime.UtcNow - t).TotalMilliseconds,
});
}
static double Now() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000.0;
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps) from the base32 secret — what an authenticator app does.
static string Totp(string secret, double unixSeconds)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
var key = new List<byte>();
int buffer = 0, bits = 0;
foreach (var c in secret.TrimEnd('='))
{
buffer = (buffer << 5) | alphabet.IndexOf(c);
bits += 5;
if (bits >= 8) { key.Add((byte)(buffer >> (bits - 8))); bits -= 8; }
}
var counter = new byte[8];
BinaryPrimitives.WriteInt64BigEndian(counter, (long)Math.Floor(unixSeconds / 30));
var h = HMACSHA1.HashData(key.ToArray(), counter);
var o = h[^1] & 15;
var value = (BinaryPrimitives.ReadInt32BigEndian(h.AsSpan(o, 4)) & 0x7fffffff) % 1000000;
return value.ToString("D6");
}
// 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.
static async Task<(double SecondsLeft, double WaitedS)> FreshWindow()
{
var left = 30 - Now() % 30;
if (left < 5) await Task.Delay(TimeSpan.FromSeconds(left + 0.2));
return (Math.Round(left, 1), left < 5 ? Math.Round(left, 1) : 0);
}
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
const site = "https://authenticationtest.com"
// The practice site publishes its test account and TOTP secret on the challenge page.
const email, password, secret = "[email protected]", "pa$$w0rd", "I65VU7K5ZQL7WB4E"
var key = os.Getenv("CDPFLEET_API_KEY")
// RFC 6238 TOTP (SHA-1, 6 digits, 30 s steps) from the decoded secret — what an authenticator app does.
func totp(k []byte, unixSeconds float64) string {
counter := make([]byte, 8)
binary.BigEndian.PutUint64(counter, uint64(math.Floor(unixSeconds/30)))
mac := hmac.New(sha1.New, k)
mac.Write(counter)
h := mac.Sum(nil)
o := h[len(h)-1] & 15
return fmt.Sprintf("%06d", (binary.BigEndian.Uint32(h[o:o+4])&0x7fffffff)%1000000)
}
func now() float64 { return float64(time.Now().UnixMilli()) / 1000 }
func round1(v float64) float64 { return math.Round(v*10) / 10 }
// 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.
func freshWindow() (secondsLeft, waited float64) {
left := 30 - math.Mod(now(), 30)
if left < 5 {
time.Sleep(time.Duration((left+0.2)*1000) * time.Millisecond)
return round1(left), round1(left)
}
return round1(left), 0
}
func launch(pw *playwright.Playwright) (string, playwright.Browser, error) {
body, _ := json.Marshal(map[string]any{"proxy": os.Getenv("PROXY_URL"), "headless": true})
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/chromium/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
msg, _ := io.ReadAll(res.Body)
return "", nil, fmt.Errorf("launch: %s %s", res.Status, msg)
}
var session struct {
SessionID string `json:"sessionId"`
WsURL string `json:"wsUrl"`
}
if err := json.NewDecoder(res.Body).Decode(&session); err != nil {
return "", nil, err
}
browser, err := pw.Chromium.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
return session.SessionID, browser, err
}
type attemptResult struct {
Attempt string `json:"attempt"`
OffsetS *int `json:"offset_s"`
SecondsLeft float64 `json:"seconds_left_in_window"`
WaitedS float64 `json:"waited_s"`
Landed string `json:"landed"`
Accepted bool `json:"accepted"`
Ms int64 `json:"ms"`
}
// 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 nil types a wrong code, otherwise
// the code for now + offset seconds.
func attempt(browser playwright.Browser, secretKey []byte, label string, offset *int) (playwright.BrowserContext, attemptResult, error) {
context, err := browser.NewContext()
if err != nil {
return nil, attemptResult{}, err
}
page, err := context.NewPage()
if err != nil {
return context, attemptResult{}, err
}
if _, err := page.Goto(site+"/totpChallenge/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return context, attemptResult{}, err
}
r := attemptResult{Attempt: label, OffsetS: offset}
r.SecondsLeft, r.WaitedS = freshWindow()
code := "000000"
if offset != nil {
code = totp(secretKey, now()+float64(*offset))
}
t := time.Now()
for _, f := range [][2]string{{"#email", email}, {"#password", password}, {"#totpmfa", code}} {
if err := page.Fill(f[0], f[1]); err != nil {
return context, attemptResult{}, err
}
}
if err := page.Click("input[type=submit]"); err != nil {
return context, attemptResult{}, err
}
if err := page.WaitForURL(regexp.MustCompile(`/login(Success|Fail)/`), playwright.PageWaitForURLOptions{Timeout: playwright.Float(60000)}); err != nil {
return context, attemptResult{}, err
}
u, _ := url.Parse(page.URL())
r.Landed, r.Accepted, r.Ms = u.Path, u.Path == "/loginSuccess/", time.Since(t).Milliseconds()
return context, r, nil
}
type output struct {
Site string `json:"site"`
SessionIDs struct {
First string `json:"first"`
Second string `json:"second"`
} `json:"session_ids"`
Clocks clocks `json:"clocks"`
Attempts []attemptResult `json:"attempts"`
StorageState struct {
Cookies int `json:"cookies"`
SessionCookies int `json:"session_cookies"`
} `json:"storage_state"`
SecondSession struct {
WithoutState signedIn `json:"without_state"`
WithState signedIn `json:"with_state"`
} `json:"second_session"`
}
type clocks struct {
ServerDateWithinRequest bool `json:"server_date_within_request"`
RequestS float64 `json:"request_s"`
BrowserMinusLocalS float64 `json:"browser_minus_local_s"`
}
type signedIn struct {
SignedIn bool `json:"signed_in"`
}
func intPtr(v int) *int { return &v }
func firstSession(pw *playwright.Playwright, secretKey []byte, out *output) (*playwright.StorageState, error) {
id, browser, err := launch(pw)
if err != nil {
return nil, err
}
out.SessionIDs.First = id
defer browser.Close()
// 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.
page, err := browser.NewPage()
if err != nil {
return nil, err
}
t0 := now()
resp, err := page.Goto(site+"/totpChallenge/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)})
if err != nil {
return nil, err
}
t1 := now()
server, err := http.ParseTime(resp.Headers()["date"])
if err != nil {
return nil, err
}
browserNow, err := page.Evaluate("Date.now()")
if err != nil {
return nil, err
}
serverS := float64(server.Unix())
out.Clocks = clocks{
ServerDateWithinRequest: serverS >= math.Floor(t0)-1 && serverS <= t1+1,
RequestS: round1(t1 - t0),
BrowserMinusLocalS: round1(toFloat(browserNow)/1000 - now()),
}
page.Close()
for _, tr := range []struct {
label string
offset *int
}{
{"wrong code", nil},
{"code from 2 windows ago (-60 s)", intPtr(-60)},
{"code from the previous window (-30 s)", intPtr(-30)},
{"code from the next window (+30 s)", intPtr(30)},
} {
context, r, err := attempt(browser, secretKey, tr.label, tr.offset)
if err != nil {
return nil, err
}
out.Attempts = append(out.Attempts, r)
context.Close()
}
context, r, err := attempt(browser, secretKey, "current code", intPtr(0))
if err != nil {
return nil, err
}
out.Attempts = append(out.Attempts, r)
state, err := context.StorageState()
if err != nil {
return nil, err
}
out.StorageState.Cookies = len(state.Cookies)
for _, c := range state.Cookies {
if c.Expires == -1 {
out.StorageState.SessionCookies++
}
}
return state, nil
}
// A new session (another browser): without the saved state, then with it.
func secondSession(pw *playwright.Playwright, state *playwright.StorageState, out *output) error {
id, browser, err := launch(pw)
if err != nil {
return err
}
out.SessionIDs.Second = id
defer browser.Close()
for _, s := range []struct {
result *signedIn
options playwright.BrowserNewContextOptions
}{
{&out.SecondSession.WithoutState, playwright.BrowserNewContextOptions{}},
{&out.SecondSession.WithState, playwright.BrowserNewContextOptions{StorageState: state.ToOptionalStorageState()}},
} {
context, err := browser.NewContext(s.options)
if err != nil {
return err
}
page, err := context.NewPage()
if err != nil {
return err
}
if _, err := page.Goto(site+"/", playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return err
}
n, err := page.Locator("text=Sign Out").Count()
if err != nil {
return err
}
s.result.SignedIn = n > 0
context.Close()
}
return nil
}
func toFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
case int64:
return float64(n)
}
return 0
}
func run() error {
secretKey, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.TrimRight(secret, "="))
if err != nil {
return fmt.Errorf("TOTP secret: %w", err)
}
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
return err
}
defer pw.Stop()
out := output{Site: site + "/totpChallenge/"}
state, err := firstSession(pw, secretKey, &out)
if err != nil {
return err
}
if err := secondSession(pw, state, &out); err != nil {
return err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(out)
fmt.Print(buf.String())
return nil
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
What we got
| Login attempt | Window left when typed | Landed on | Accepted | Submit → result (ms) |
|---|---|---|---|---|
| wrong code | 11.4 s | /loginFail/ | no | 947 |
| code from 2 windows ago (-60 s) | 8.4 s | /loginFail/ | no | 1229 |
| code from the previous window (-30 s) | 4.5 s → waited for the next | /loginSuccess/ | yes | 1105 |
| code from the next window (+30 s) | 21.1 s | /loginSuccess/ | yes | 1543 |
| current code | 15.3 s | /loginSuccess/ | yes | 2544 |
| 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
- A wrong code and the code from two windows ago were refused; the previous, current and next windows' codes were all accepted. This site tolerates one 30-second step of clock difference in either direction. Don't count on that everywhere: RFC 6238 recommends allowing at most one step, and stricter servers allow none.
- Generate the code where your program runs. The code depends only on the secret and your clock; the remote browser just types it. What has to agree is your clock and the server's: in every run the site's
Dateheader (sent by its CDN edge) fell between our clock just before and just after the request. The remote browser's clock was within half a second of ours, but it plays no part. - Don't spend the drift margin on latency. The tolerance is there for clock differences. Make the code after the form has loaded, right before typing it, and wait for the next window when fewer than 5 seconds are left — the rows marked "waited for the next" in the table. On a server with no tolerance, a code typed in its last second fails.
- Log in once, then reuse the session: the saved state held a single session cookie. A new session — a different browser — was signed in with it and signed out without it. This site remembered the login, not the device, so the next session skips the second factor until the site ends the login. Some sites tie a login to the device (persisting logins), and apps that keep the login in IndexedDB need one more option (IndexedDB login state).
- Keep the TOTP secret in a secret store, like a password: whoever holds it can produce every future code.