Skip to content

Authenticator

Apps under test ask for MFA. Enrolment shows a QR code and a setup key, and every later sign-in asks for a code from an authenticator app. A test needs what a person keeps on their phone: something that holds the secret and gives the code of the moment. Waridex keeps the secret the test hands it at enrolment and serves the codes.

An authenticator is one secret — what an authenticator app calls an account, and what the dashboard calls an account. It belongs to a workspace, carries tags and may be given an expiry, as an inbox is.

Pass the otpauth:// URI behind the enrolment QR code, which is what the app under test shows beside it:

Terminal window
curl -sS --fail-with-body "$API/v1/authenticators" \
-H "Authorization: Bearer $WARIDEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"otpauthUri": "otpauth://totp/Acme:sara@acme.io?secret=JBSWY3DPEHPK3PXP&issuer=Acme", "tags": ["run-4711"]}'

Or pass the setup key the enrolment screen prints, with the parameters it names:

{ "secret": "JBSW Y3DP EHPK 3PXP", "issuer": "Acme", "account": "sara@acme.io", "digits": 6, "period": 30 }

POST /v1/authenticators needs the authenticator:use scope. Exactly one of otpauthUri and secret is required. With a URI the secret and its parameters come from the URI, so passing secret, issuer, account, algorithm, digits or period beside it is 400 request.invalid; tags and expiresInSeconds are yours either way.

FieldMeaning
otpauthUrithe Key URI behind the QR code. Its label is split at the first : into issuer and account, the issuer parameter winning over the label’s prefix, as authenticator apps do; unknown parameters are ignored. One that does not parse, or has no secret, is 400 authenticator.invalid_uri
secretthe setup key: Base32, ignoring case, spaces and hyphens, padding optional, decoding to 10 to 64 bytes, else 400 authenticator.invalid_secret
issuer, accountoptional labels of up to 100 characters each. With both empty, the list shows the id
algorithmSHA1 (the default), SHA256 or SHA512
digits6 (the default) or 8
period30 seconds (the default) or 60
tagsup to ten, as on an inbox: typically the CI run. See tags and the per-run purge
expiresInSeconds180 seconds to one year. When it passes, the authenticator stops serving codes at once and the hourly job deletes it. Without it, it lives until you delete it: a long-lived staging user keeps its secret

Waridex implements TOTP (RFC 6238) and nothing else. Another algorithm, digit count or period, and an otpauth://hotp/ URI, are 400 authenticator.unsupported naming the parameter: nothing is guessed or rounded.

The response is the authenticator with code, the enrolment code, so the test can finish the enrolment screen in the same step. A create begun in the last seconds of a window holds for the next one before it answers — at most five seconds, since a new authenticator has no window of its own to skip — so the code it returns has time left on it; like a code request, it can answer 409 authenticator.window_lapsed instead.

{
"id": "0199a3c4-7e11-7c42-8b05-9a1f2d3c4b5e",
"issuer": "Acme",
"account": "sara@acme.io",
"algorithm": "SHA1",
"digits": 6,
"period": 30,
"tags": ["run-4711"],
"createdAt": "2026-09-28T09:00:00.000+00:00",
"code": {
"code": "844716",
"validFrom": "2026-09-28T09:00:00+00:00",
"validTo": "2026-09-28T09:00:30+00:00",
"serverTime": "2026-09-28T09:00:04.118+00:00",
"waitedMs": 0
}
}

The secret never leaves the API. No response carries it, nor the URI nor a QR code, and there is no export and no “show secret”: a test that needs the secret again enrols a new authenticator.

Waridex protects it with a key of its own, so a create and a code request both answer 503 authenticator.secret_unavailable while that key cannot be read — the one failure here that is neither the caller’s doing nor worth retrying at once.

Terminal window
curl -sS --fail-with-body "$API/v1/authenticators/$AUTHENTICATOR_ID/codes" \
-H "Authorization: Bearer $WARIDEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'

POST /v1/authenticators/{id}/codes serves a code; it is a POST because serving one changes state, so no client, proxy or prefetch may repeat it on its own. The body may be left out, and both fields default:

FieldDefaultMeaning
unusedtrueserve a window no earlier request served. Two parallel requests for one authenticator therefore never get the same window
minValiditySeconds5the window must have at least this long left when it is served, from 0 to 15

The answer is the code with its window:

{
"code": "651264",
"validFrom": "2026-09-28T09:00:30+00:00",
"validTo": "2026-09-28T09:01:00+00:00",
"serverTime": "2026-09-28T09:00:31.204+00:00",
"waitedMs": 26118
}

A window that has not begun is held for — the call waits until it starts, one period at most, and waitedMs says how long it was held. So a code request straight after an enrolment returns the next window’s code after a hold: an app that refuses a code already used in its window, as RFC 6238 recommends, accepts it. With unused=false the current window is served whether or not it was served before, which skips that wait. It does not skip minValiditySeconds, though: asked in the last seconds of a window, the call still moves to the next one and holds for it, so a test that must never wait sends minValiditySeconds: 0 as well.

serverTime is the API’s clock when the code was served. The API’s clock is the authenticator’s clock, so a test whose code was refused can compare the two.

  • A window a concurrent unused request claimed first is 409 authenticator.window_claimed. The same answer comes at once, without a hold, when the windows already served are ahead of the API’s clock — it was set back, or a database was restored beside a host behind it — and then Retry-After can span several periods, not just the next window.
  • A window that ran out while the request waited for its turn is 409 authenticator.window_lapsed.

Both carry Retry-After with the seconds until the next window, and asking again is the whole answer: a caller retries after it, and the SDKs will do so within their timeout once they ship.

Every code served is one row: the enrolment code of a create, and every code request, the dashboard’s included. GET /v1/authenticators/codes lists them newest first, each with its authenticator’s issuer and account, the code, the window, the kind and who asked, filtered by authenticator, since, apiKey or member.

Nothing else is recorded: adding, listing, reading and deleting authenticators write no row. Codes are stored as served — a served code is past or current, and whoever can read the log can ask for a code anyway — so the log shows which code a refused sign-in was given, and in which window. The rows follow the workspace’s retention and go with their authenticator.

CallReturns
GET /v1/authenticatorsthe workspace’s authenticators, newest first; tag, q (issuer or account contains) and paging with limit and offset, and the API’s serverTime
GET /v1/authenticators/{id}one authenticator
DELETE /v1/authenticators/{id}deletes it with its tags and log rows
DELETE /v1/authenticators?tag=run-4711deletes every authenticator carrying the tag and returns the count; a tag nobody carries is 200 with zero, and no tag is 400 tag.required

Every route needs authenticator:use. Tag the authenticators a CI run enrols and purge them in the same teardown as the run’s inboxes; an expired one stops serving codes at once, so an expiry is a second net under a teardown that never ran.

The Authenticator page lists a workspace’s accounts, adds one from a QR code image, a URI or a setup key, and shows a row’s current code on request. The code counts down and hides when its window ends, and nothing polls: every code served is a log row, and a page left open would fill the log.

An account's card on the dashboard's Authenticator page: its issuer, account and parameters on the left, and the code shown with the seconds left in its window, a countdown ring and a Copy button.

Free holds three authenticators; Team and Business hold any number. An authenticator counts while it exists, so deleting one or letting it expire frees its place, and past the number a create is 403 authenticator.limit_reached with limit and tier. A downgrade keeps every existing authenticator serving codes and refuses new ones past the number.

authenticator:use is one scope for the whole channel, since a test that enrols a user needs every verb. It can go on an API key, and the Member and Admin workspace roles hold it; a Viewer does not, because reading a code is signing in as the enrolled user.