urlcap

TOTP

Automating TOTP codes for staging and QA workflows

Updated · Related endpoint: /api/v1/totp

Read this first — what this guide is and isn't for.

This is about internal automation against systems you own — CI smoke tests against a staging environment, machine-to-machine 2FA against an internal service, Playwright/Cypress login flows for a test account. It is not a recipe for sending your personal production 2FA secrets to a third-party API. For your personal accounts, use an authenticator app on a device you control.

The problem: automated tests still need to pass 2FA

You enforced TOTP on your staging environment to mirror production. Now your end-to-end tests, your nightly CI smoke run, and the Playwright suite that takes screenshots before each release all break on the 2FA screen. You can't disable 2FA in staging (it would mask real regressions); you can't share a single human's authenticator app with CI (the secret would leak into screenshots and logs).

The standard fix is to provision a test-only test account, store its TOTP shared secret in your secret manager (Vault / AWS Secrets Manager / Doppler / GitHub Actions secrets…), and compute codes from that secret on demand. /api/v1/totp is the API for the "compute codes from that secret" step — stateless, no library to import, callable from any language.

Step 1 — get an otpauth:// URI for the test account

When the test account is enrolled in 2FA, the server shows a QR code that encodes an otpauth:// URI. Capture that URI before scanning anything into an authenticator app — the URI is the full shared secret, the algorithm, the digit count, and the period in one string.

otpauth URI
otpauth://totp/Acme%20Staging:ci@acme.io?secret=JBSWY3DPEHPK3PXP&period=30&digits=6&algorithm=SHA1

Store the whole URI in your secret manager under a key like STAGING_CI_TOTP_URI.

Step 2 — generate a code from curl

generate · curl
# URL-encode the otpauth URI — most shells do this with `jq -sRr @uri` or python -c
URI=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$STAGING_CI_TOTP_URI")

curl -s "https://urlcap.com/api/v1/totp?uri=$URI" \
  -H "Authorization: Bearer $URLCAP_KEY"

Response:

200 OK
{
  "version": "1",
  "requestId": "9f1c0b7a-3e2d-4a51-9b88-2f6c1e7d4a02",
  "data": {
    "code": "492039",
    "digits": 6,
    "period": 30,
    "algorithm": "SHA1",
    "expiresIn": 14
  }
}

expiresIn is the seconds left in the current 30-second window. If it's small (say < 3), the code is about to rotate — wait one period and refetch rather than submitting a stale value to a tight server.

Step 3 — wire it into Playwright

The pattern: fetch a fresh code, type it in. If you're paranoid about edge-case races, fetch twice and use the second value when the first one's expiresIn is small.

login.spec.ts · Playwright + TypeScript
import { test, expect } from "@playwright/test";

async function totpCode(): Promise<string> {
  const url  = new URL("https://urlcap.com/api/v1/totp");
  url.searchParams.set("uri", process.env.STAGING_CI_TOTP_URI!);
  const res  = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.URLCAP_KEY}` },
  });
  const json = await res.json();
  // If we're at the tail of the current window, wait it out so the code we type isn't stale.
  if (json.data.expiresIn < 3) {
    await new Promise(r => setTimeout(r, (json.data.expiresIn + 1) * 1000));
    return totpCode();
  }
  return json.data.code;
}

test("staging login with 2FA", async ({ page }) => {
  await page.goto("https://staging.acme.io/login");
  await page.getByLabel("Email").fill("ci@acme.io");
  await page.getByLabel("Password").fill(process.env.STAGING_CI_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.getByLabel("Verification code").fill(await totpCode());
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Step 3 (alt) — call from a Python test fixture

conftest.py · pytest
import os, json, time, urllib.parse, urllib.request
import pytest

@pytest.fixture
def totp_code():
    def _code():
        uri = urllib.parse.quote(os.environ["STAGING_CI_TOTP_URI"])
        req = urllib.request.Request(
            f"https://urlcap.com/api/v1/totp?uri={uri}",
            headers={"Authorization": f"Bearer {os.environ['URLCAP_KEY']}"},
        )
        d = json.load(urllib.request.urlopen(req, timeout=5))["data"]
        if d["expiresIn"] < 3:
            time.sleep(d["expiresIn"] + 1)
            return _code()
        return d["code"]
    return _code

What not to do

How urlcap handles the URI

When urlcap is simpler than doing this yourself

You can absolutely compute TOTP in process. Every popular language has a library — Node has otplib, Python has pyotp, Go has pquerna/otp, Java has aerogear-otp-java. They all work. So when is the API simpler?

ApproachUse when…
In-process library (otplib, pyotp…) Tests are written in a language with a maintained TOTP lib and you're comfortable bundling it. Lowest latency, no third-party trust.
Authenticator app (Authy / 1Password / Google Authenticator) A human is logging in. Not automatable.
urlcap /api/v1/totp You want the same TOTP code-generation behaviour across heterogeneous CI runners (a bash job, a GitHub Action, a Postman flow, a no-Node tool) without auditing a per-language TOTP library each time.

Pricing & limits

Each TOTP call is one request unit. A test suite that logs in once per run and runs 100 times a day fits comfortably inside the Free tier. See /pricing.

Create a free API key and wire this into your test suite.

No card. 1,000 requests / month on the free tier.