2FA by allme
Use allme as the second factor for your own login. When a person signs in to your product, you ask them to approve it inside the allme app — a push, a card, a biometric tap. You raise the challenge, the person approves or denies on their phone, and you read the outcome by poll or webhook. Optional number matching defeats push-fatigue and shoulder-surfing.
/api/service-2fa/*, which a data client receives at registration. An older client that has not been backfilled yet returns 403 {"error":"Client not authorized for this endpoint","error_key":"auth.client_endpoint_forbidden"} on the calls below — register a fresh data client on the portal if you hit that.How it fits together
- Enroll — the person opts your service in as a 2FA approver, once (a consent on the allme surface).
- Challenge — at each login you
POSTa challenge naming the person by their share code. allme pushes a card to their devices. - Approve / deny — the person confirms in-app (biometric-gated), optionally re-typing two matching digits you show on your login page.
- Read the outcome — poll the challenge (authoritative) or receive the
2fa_challenge_completedwebhook. Approve the login only onapproved.
1. Enroll the person
Only an enrolled person can be challenged, and enrolling needs your company on the Pro level or above (an enrollment that already exists keeps working). Enrollment is a one-time consent on the same web.allme.fyi/auth surface as Sign in with allme, using mode=2fa_enroll. Point a button at it once, keyed to the service the person is (or will be) connected to:
<a href="https://web.allme.fyi/auth?client_id=idw_...&mode=2fa_enroll
&redirect_uri=https%3A%2F%2Fapp.example%2Fsecurity&state=RANDOM">
Use allme for 2-step verification
</a>
The person confirms, and a service_2fa_enrollments record is written. No code is minted and no scope or claim machinery runs — enrollment is its own grant, separate from authorizing an OAuth app or running a connect. An OIDC-only relying party enrolls through this same surface; there is no OIDC path for enrollment itself. The person can revoke you at any time from their app. Attempting to challenge a person who is not enrolled returns 404.
2. Raise a challenge
On the service data-client credentials (the same auth as the rest of the service API), POST a challenge naming the person by their share code. Like every other authenticated call, it goes to the api_url your token response returned, not to the global front door (Regions):
curl -s -X POST https://api-eu.allme.fyi/api/service-2fa/challenges \
-H "Authorization: Bearer $SERVICE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"share_code":"2I6UF3","context":"Sign-in from Chrome on macOS","idempotency_key":"login-8f3c1a"}'
# → { "challenge_id": "...", "status": "pending", "expires_at": "2026-07-23T09:12:00+00:00",
# "matching_digits": "47" } // matching_digits only when number matching is on
share_code— the person (must be enrolled), else 404. Either the code their app shows them, or a custom share code an allme operator has assigned to that profile: both resolve to the same person (see custom share codes). Theshare_codethe webhook body below carries back is always the generated code.context— optional plain text shown on the person's card (≤200 chars). Give them enough to recognise the login (device, location, time).idempotency_key— required (≤64). A repeat with the same key inside the challenge TTL returns the same challenge and sends no second push, so a retried request never double-prompts the person.
Errors are 404 (unknown / not-enrolled share code), 429 (rate limit, or too many challenges already pending for this person) and 503 (twofa.unavailable — our challenge store could not be reached; transient, retry; or db.writes_paused — saving is paused, nothing was written: wait Retry-After seconds and send the same request again, see API → errors; or platform.out_of_order — allme is temporarily out of order, the request was not processed: wait Retry-After (300 seconds) and send the same request again, see API → errors). A malformed request is a 400 (twofa.share_code_required, twofa.idempotency_key_required, twofa.context_too_long). The challenge endpoint carries no level gate of its own; the data client it rides needs your company on the Basic level or above.
3. Read the outcome
Poll — the record
Poll the challenge until it leaves pending:
curl -s https://api-eu.allme.fyi/api/service-2fa/challenges/CHALLENGE_ID \
-H "Authorization: Bearer $SERVICE_TOKEN"
# → { "status": "pending", "expires_at": "..." } // still waiting
# → { "status": "approved", "completed_at": "..." } // once — then burned
# → 410 { "error": "gone", "error_key": "twofa.result_consumed" } // a later read
status is pending while open, then a terminal approved | denied | expired | revoked; a read after that answers 410 twofa.result_consumed (an SDK raises it as ApiError). The poll is the record of the outcome: the first read of a terminal state delivers it and burns it, so read it once and persist your own decision. Grant the login only on approved; treat everything else — including expired — as a refusal.
A 503 is not an outcome — retry it. It means our challenge store could not be read, so the outcome is unknown and has not been burned; polling again returns it. Treating a 503 as a refusal would drop a login the person already approved. Every SDK raises its ApiError for this (carrying error_key: "twofa.unavailable"), including from waitForResult, so it can never be mistaken for a terminal status. A 503 with error_key: "platform.out_of_order" on the poll is the same: the request was not processed and the outcome has not been burned, so poll again after Retry-After (300 seconds).
Webhook — the push alternative
Rather than tight polling, subscribe to 2fa_challenge_completed on your service webhook (add it to the webhook's event list, or leave the list empty for all events). The body carries the same terminal state:
{
"event": "2fa_challenge_completed",
"challenge_id": "...",
"share_code": "2I6UF3",
"status": "approved",
"completed_at": "2026-07-23T09:11:40+00:00"
}
Number matching
Number matching is a per-service setting, and there is no control for it in the allus portal — you switch it on over the API:
# $API_URL = the api_url your token response returned (your home region)
curl -X PUT "$API_URL/api/services/{serviceId}/number-matching" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"number_matching": true}'
$TOKEN is a client_credentials token for an account API client (portal → Settings → API clients) holding the Service management capability. Your per-service data client cannot do this — its whitelist covers the company-data and 2FA surfaces, not /api/services*. Note the path: /require-2fa is a different, unrelated per-service flag.
When number matching is on, each challenge returns two digits in matching_digits. Show those digits on your login page; the person must type them back into the allme app to approve. The check is decisive because of where it runs:
- The server adjudicates the digits. The app sends what the person typed; allme compares it against the challenge server-side. The digits never travel on the push or the poll payload, so a device that only received the push cannot self-approve.
- Three wrong entries deny the challenge. A shoulder-surfed or phished prompt turns into a denial rather than an open door.
Number matching turns a one-tap approval (vulnerable to a person reflexively approving a push they did not initiate) into a proof that the person is looking at your login screen right now.
The SDK quickstart
Each service SDK exposes a twoFactor sub-client on the same data client — challenge() to raise one, result() to poll once, or waitForResult() to poll to a terminal outcome for you. No new config; the return models are identical across all six languages.
from allus_company_data import Client
client = Client.from_config("allus.json") # your existing service data client
# 1) raise a challenge at login
ch = client.two_factor.challenge("2I6UF3", context="Sign-in from Chrome", idempotency_key="login-8f3c1a")
if ch.matching_digits:
show_on_login_page(ch.matching_digits) # number matching is on
# 2) wait for the terminal outcome — waitForResult polls result() for you
# (or wake on the 2fa_challenge_completed webhook)
res = client.two_factor.wait_for_result(ch.challenge_id)
if res.status == "approved":
grant_login()import { Client } from "@allus-fyi/company-data";
const client = Client.fromConfig("allus.json");
const ch = await client.twoFactor.challenge("2I6UF3", {
context: "Sign-in from Chrome",
idempotencyKey: "login-8f3c1a",
});
if (ch.matchingDigits) showOnLoginPage(ch.matchingDigits);
const res = await client.twoFactor.waitForResult(ch.challengeId);
if (res.status === "approved") grantLogin();import (
"context"
"log"
"github.com/allus-fyi/company-data-go/companydata"
)
client, err := companydata.FromConfig("allus.json") // your existing service data client
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// 1) raise a challenge at login
ch, err := client.TwoFactor().Challenge(ctx, "2I6UF3", "login-8f3c1a", "Sign-in from Chrome")
if err != nil {
log.Fatal(err)
}
if ch.MatchingDigits != "" {
showOnLoginPage(ch.MatchingDigits) // number matching is on
}
// 2) wait for the terminal outcome — WaitForResult polls Result for you
// (0, 0 takes the defaults: 600s budget, 2s between polls)
res, err := client.TwoFactor().WaitForResult(ctx, ch.ChallengeID, 0, 0)
if err != nil {
log.Fatal(err)
}
if res.Status == "approved" {
grantLogin()
}using Allus.CompanyData;
var client = Client.FromConfig("allus.json"); // your existing service data client
// 1) raise a challenge at login
var ch = await client.TwoFactor.ChallengeAsync("2I6UF3", "login-8f3c1a", context: "Sign-in from Chrome");
if (ch.MatchingDigits is not null)
ShowOnLoginPage(ch.MatchingDigits); // number matching is on
// 2) wait for the terminal outcome — WaitForResultAsync polls ResultAsync for you
var res = await client.TwoFactor.WaitForResultAsync(ch.ChallengeId);
if (res.Status == "approved")
GrantLogin();import fyi.allme.allus.companydata.Client;
import fyi.allme.allus.companydata.TwoFactorChallenge;
import fyi.allme.allus.companydata.TwoFactorResult;
Client client = Client.fromConfig("allus.json"); // your existing service data client
// 1) raise a challenge at login
TwoFactorChallenge ch = client.twoFactor().challenge("2I6UF3", "login-8f3c1a", "Sign-in from Chrome");
if (ch.matchingDigits() != null) {
showOnLoginPage(ch.matchingDigits()); // number matching is on
}
// 2) wait for the terminal outcome — waitForResult polls result() for you
TwoFactorResult res = client.twoFactor().waitForResult(ch.challengeId());
if ("approved".equals(res.status())) {
grantLogin();
}use Allus\CompanyData\Client;
$client = Client::fromConfig('allus.json');
$ch = $client->twoFactor()->challenge('2I6UF3', context: 'Sign-in from Chrome', idempotencyKey: 'login-8f3c1a');
if ($ch->matchingDigits !== null) showOnLoginPage($ch->matchingDigits);
$res = $client->twoFactor()->waitForResult($ch->challengeId);
if ($res->status === 'approved') grantLogin();The argument set and both return models are identical in all six languages — only the naming follows each language's idiom. Installing and configuring the client is on the SDKs page; this needs no config beyond the data client you already have.
Two conveniences the SDKs add on top of the raw endpoints: waitForResult (above) polls to the first terminal state and raises on timeout, so you never hand-roll the loop; and a per-person pending-cap 429 (too many challenges already open for this person) surfaces immediately as an ApiError instead of being retried — a retry can't clear it. For a no-redirect, server-to-server enrollment the OAuth helper also accepts mode=2fa_enroll with response_mode=detached, and its poll_result returns {enrolled: true} once the person confirms.
What the person sees
The challenge arrives as a push and a card in the allme app's Requests screen, showing your service name and the context you supplied. Approve is protected by the person's biometric/passcode unlock — the same gate as revealing a private field — so an approval is always a fresh, deliberate act; Deny is one tap with no gate. Approving or denying on one device dismisses the card on the person's other devices instantly. An unanswered challenge simply expires.
Security notes
- Only enrolled people can be challenged. A person opts your service in and can revoke it any time; a challenge to a non-enrolled person is a 404, never a silent no-op.
- Grant only on
approved. Treatdenied,expired,revokedand a consumed (410) read as refusals. Read the poll once and persist your decision. - Prefer number matching for anything sensitive — it upgrades a reflexive approval into a proof the person is on your login screen, and the server enforces it.
- Always send an
idempotency_keyso a retried login request reuses one challenge instead of stacking prompts. - Verify the webhook signature and remember the poll is authoritative — the webhook only tells you when to read it.