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://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
# 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:
{
"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.
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
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
- Don't embed the otpauth URI in frontend JavaScript. If your browser bundle can read the secret, so can anyone who opens the dev-tools console.
- Don't reuse your personal 2FA secret. Provision a separate test account with its own enrolment. Personal authenticator-app secrets belong on a device you control, not in a CI secret store and not in a third-party API call.
- Don't log the URI. If your test runner echoes environment variables on failure, scrub
STAGING_CI_TOTP_URIfrom the dump. - Don't share one test account across many CI pipelines if rate-limited 2FA failures could lock it. Provision one per pipeline or use a high-tolerance test tenant.
How urlcap handles the URI
- The
uriis processed in memory for the duration of one request and is never persisted — no DB write, no disk write, no caching. - The
uriparameter is redacted from request logs before any log line is written and is excluded from analytics. - Transport is TLS 1.2+ only; HSTS with
includeSubDomains. - Full posture: /security.
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?
| Approach | Use 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.
Related
Create a free API key and wire this into your test suite.
No card. 1,000 requests / month on the free tier.