Widget
Add one button to your page. The user enters their number and sends the code from that phone by SMS. Your server gets the verified number with one call.
The widget puts a "Continue with phone" button on your page. When the user clicks it, a modal opens in the middle of the page over a blurred backdrop. First the user enters their phone number. Then the modal shows the code and the 150₮ price. A copy button sits next to the code. A phone gets a "Send SMS" button; a computer gets a QR code with an "Open SMS app" button under it. As soon as the user sends the code from that number, the modal shows the verified state and closes itself.
Sign in
Sign in to Example with your phone number.
The modal is loaded from verify.2fa.mn, so your page cannot read or alter what is inside it. The rule that the price is shown before the button, and the button is hidden once verified, is always met. Status arrives by server push (SSE), so you write no waiting or polling code.
When the user verifies, the widget hands your page a short-lived token. Your server swaps it for the verified number with one call. No webhook code is needed.
Get an API key and add your site
In the Dashboard, open Widget and create a publishable key starting with pk_test_. Add the origin of each site where the widget will run, for example https://example.mn, or http://localhost:3000 while developing. localhost and 127.0.0.1 are allowed on test keys only.
Paste the snippet
Put this where the button should appear.
<div id="twofa"></div>
<script src="https://verify.2fa.mn/embed/v1/embed.js"></script>
<script>
TwoFA.button('#twofa', { key: 'pk_test_...', state: '<random value from your session>' })
.on('verified', ({ token }) => fetch('/login', { method: 'POST', body: token }));
</script>Your server creates state fresh for each sign-in attempt and keeps it in that user's session. For example crypto.randomBytes(16).toString('base64url') in Node.js, secrets.token_urlsafe(16) in Python or bin2hex(random_bytes(16)) in PHP. See below for why it matters.
The verified event carries a token. Send it to your own server.
Redeem the token and check state
Call POST /v1/verifications once with your secret key (sk_test_...) and the JSON body {"token": "..."}. The token is consumed by that call. The older GET /v1/verifications/{token} form still works, but it puts the token in a URL, where logs can keep it, so prefer POST. Always compare the state in the response with the value in your session, and refuse the sign-in if they differ.
curl https://api.2fa.mn/v1/verifications \
-H "Authorization: Bearer $TWOFA_KEY" \
-H "Content-Type: application/json" \
-d "{\"token\": \"$TOKEN\"}"const res = await fetch('https://api.2fa.mn/v1/verifications', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TWOFA_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ token }),
});
if (!res.ok) throw new Error((await res.json()).error.code);
const { phone, state } = await res.json();
if (state !== req.session.twofaState) throw new Error('state_mismatch');
// phone is verified. Sign the user in.import hmac, os, requests
res = requests.post(
"https://api.2fa.mn/v1/verifications",
headers={"Authorization": f"Bearer {os.environ['TWOFA_KEY']}"},
json={"token": token},
timeout=10,
)
res.raise_for_status()
data = res.json()
if not hmac.compare_digest(data["state"], session["twofa_state"]):
abort(403)
# data["phone"] is verified. Sign the user in.$ch = curl_init("https://api.2fa.mn/v1/verifications");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("TWOFA_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["token" => $token]),
CURLOPT_TIMEOUT => 10,
]);
$data = json_decode(curl_exec($ch), true);
if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 200) {
throw new Exception($data["error"]["code"]);
}
if (!hash_equals($_SESSION["twofa_state"], $data["state"] ?? "")) {
http_response_code(403);
exit;
}
// $data["phone"] is verified. Sign the user in.On success you get 200:
{
"session_id": "ses_8f3k2m",
"phone": "99112233",
"verified_at": "2026-12-01T10:02:14Z",
"mode": "test",
"state": "q9Xh2LwP0aT7bV4e"
}Errors:
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_token | The token is malformed or not recognised |
| 404 | not_found | The token belongs to another client or another mode (test/live) |
| 409 | token_used | The token was already redeemed |
| 410 | token_expired | The token has expired |
Two addresses: api.2fa.mn and verify.2fa.mn
Your page loads the widget from verify.2fa.mn. Your server calls api.2fa.mn with your secret API key to redeem the token. It is the same service. The widget lives on its own address so it runs isolated, under its own strict security policy (CSP), and never touches the secret-key API.
If your page sets a CSP, add https://verify.2fa.mn to script-src and frame-src. If your server limits outbound traffic, allow api.2fa.mn.
Why send an SMS?
The user types their number in the first step, but typing a number does not prove it is theirs. The operator delivers the SMS to 150073 together with the sender's number, so sending the code from that phone proves the SIM is in the user's hands. An SMS from another number is refused: the widget says it came from another number and asks the user to send it from the right one.
If you already know the user's number (for example, it is on their account), pass it as phone. The number step is then skipped and the user sees the code straight away.
Why state?
state ties the token to the browser session that started it. Without it, a token obtained in one browser could be slipped into someone else's sign-in, logging that person in with the attacker's number. state is bound into the token and returned by POST /v1/verifications. Comparing it with the value in your session proves the token started in this browser.
- 1 to 128 characters, only
A-Z a-z 0-9 . _ ~ -. Anything else makes the widget returninvalid_request. - Create a new one for every sign-in attempt.
- If it does not match the value in your session, do not sign the user in.
Options and events
TwoFA.button(target, options) renders the button inside the element and returns a handle. A session is created only when the button is clicked.
| Name | Description |
|---|---|
target | A CSS selector or an element, for example '#twofa' |
options.key | Your publishable key (pk_test_... or pk_live_...) |
options.state | A random value from your session. Bound into the token and returned by redeem. Recommended every time |
options.phone | A known 8-digit number, for example '99112233'. Skips the number step, and only an SMS from it is accepted. An invalid value returns invalid_phone |
options.lang | Language: mn or en. Default mn |
options.accent | Button colour, '#rrggbb' only. Anything else falls back to #1d3fc4 |
options.radius | Corner radius in px, 0 to 24. Default 12 |
options.size | Button style: 'full' (default) or 'compact' |
.on('open', fn) | The modal opened |
.on('close', fn) | The modal closed |
.on('verified', fn) | The number is verified. fn({ token }) |
.on('expired', fn) | The code expired. The user can get a new one in the modal |
.on('error', fn) | An error occurred. fn gets the error code as a string, for example 'origin_not_allowed' |
.open(), .close() | Open or close the modal from code |
.destroy() | Remove the button and modal from the page |
Esc, the close button or a click on the backdrop closes the modal. While it is open the page does not scroll, and focus returns to the button when it closes.
Inline on the page
To show the widget directly on the page, without the button and modal, use TwoFA.mount. The key, lang, phone and state options, the verified, expired and error events and the redeem call are the same. It also fires ready when the widget first appears. It has no .open(), .close(), accent, radius or size.
TwoFA.mount('#twofa', { key: 'pk_test_...', state: '...' })
.on('verified', ({ token }) => fetch('/login', { method: 'POST', body: token }));Dashboard settings
In the Dashboard under Widget, separately for each mode (test/live):
- Publishable key and allowed sites
- Theme (light, dark, automatic), accent colour, corner radius, language
- Button style: full or compact
- Display name, shown in the modal header as
Sign in to {name} - An example that shows your changes before you save, and a button to try the real widget with your test key
- A live list of incoming verifications
You can also switch the widget off.
Security
- A publishable key is meant to be public. It can only start widget sessions; it cannot call the
/v1API. - The widget works only on the sites you added. On any other site it shows an error message.
- The token is single-use and short-lived. The default lifetime is 10 minutes, and it is a setting.
- Always redeem the token on your own server and check
state. Never trust a token, or a "verified" claim, that exists only in the browser. - Your secret key (
sk_...) never goes to a browser.
Offline verification (JWKS)
The token is an ES256 JWT. If you prefer not to make a call, you can verify it with the public keys at https://api.2fa.mn/.well-known/jwks.json. Test mode tokens are signed with a separate test signing key, and the JWKS lists both the test and the live public key, so a valid signature alone does not make a token live. You must check all of:
- The signature matches a key in the JWKS (ES256)
issis exactlyhttps://api.2fa.mn(test tokens carryhttps://api.2fa.mn/test)audis exactly your client id (test tokens carry<client id>:test)modeisliveexphas not passedjtiis single-use: keep track of it yourself and reject repeats
Offline checks do not mark the token as used, so for the simple case use the single call above.
Webhooks
The verification.succeeded webhook still fires for widget verifications. Its data carries an extra "source":"widget" field. If you already set up webhooks as described in Webhooks & signatures, nothing changes.
What's next
Was this page helpful?