Blog
DEV TIPS & TRICKS

Real MFA in an Afternoon: TOTP, SMS, and Email Codes with Polaris (Part 3)

Updated:
Jul 21, 2026
Jul 21, 2026
6 min read
Polaris Logo
By
Antonio Ramirez Cobos
,
Co-founder & CTO at 2am.tech
This builds on part 2, so if you have not seen the login flow yet, start there.

Multi-factor authentication was the feature I expected to hate building and somehow ended up enjoying. Polaris supports three factor types behind one consistent verification flow: an authenticator app (TOTP), SMS codes, and email codes. On top of that, you get ten single-use recovery codes and step-up re-authentication for sensitive actions.

Every response below comes from a live run, so what you see is exactly what the module returns.

Enrolling an authenticator app (TOTP)

The user is logged in and wants to add TOTP. They hit the enroll endpoint with an empty body:

curl -s https://api.example.com/v1/auth/mfa/totp/enroll \
  -H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...'
{
  "data": {
    "factor_id": "019ee088-083f-7012-8b76-9468c6db7584",
    "secret": "JBSWY3DPEHPK3PXP63MFK7RUZAOYTCURNXEB",
    "otpauth_uri": "otpauth://totp/Univeros%3Aada%40example.com?issuer=Univeros&secret=JBSWY3DPEHPK3PXP63MFK7RUZAOYTCURNXEB",
    "qr_svg": "<?xml version=\"1.0\"?><svg ...>...</svg>"
  }
}

The secret is a base32 shared secret. It is shown exactly once, here, and never again. The otpauth_uri is the standard provisioning URI an authenticator app understands. The qr_svg is a ready-to-render SVG of that URI, so your frontend drops it straight into the page and the user scans it with Google Authenticator, 1Password, Authy, whatever they like. I went with SVG on purpose: it is crisp at any size and needs no gd or imagick extension, so enrollment works on a stock PHP install. The TOTP secret is encrypted at rest, because the server has to recompute codes against it.

Then the user types the six digits their app shows, and you confirm:

curl -s https://api.example.com/v1/auth/mfa/totp/confirm \
  -H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...' \
  -H 'Content-Type: application/json' \
  -d '{"factor_id":"019ee088-083f-7012-8b76-9468c6db7584","code":"492817"}'
{
  "data": {
    "status": "confirmed",
    "recovery_codes": [
      "be6nk-hfb4t", "sbje5-jnbh5", "wmdkh-knypa", "vc2g2-gk3sy", "gxv54-vhd6z",
      "vybvr-xuwyw", "dcagu-cvzsr", "rfntt-u6q62", "u3xy4-vwr6a", "qqhpa-quhmy"
    ]
  }
}

Those ten recovery codes appear only when this is the user's first confirmed factor. They are shown once and stored hashed (as keyed HMACs, from an unambiguous base32 alphabet so there is no 0/O or 1/l confusion). If the user later loses their phone, a recovery code gets them back in.

One subtle thing the test suite taught me: confirming a factor consumes the current 30-second TOTP step, so the next code you submit during a real login has to be from the following step. Replay the same code inside that window and it gets rejected. That’s the anti-replay fence doing its job.

SMS and email factors

Same idea, different channel. SMS enrollment takes a phone number in E.164 format and texts a code:

curl -s https://api.example.com/v1/auth/mfa/sms/enroll \
  -H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...' \
  -H 'Content-Type: application/json' \
  -d '{"phone_e164":"+14155550101"}'
{ "data": { "factor_id": "019ee088-087f-7b36-884d-a57b4113d728", "destination": "+1 *** *** 0101" } }

The destination comes back masked every time, so neither a response nor a log line ever reveals the full number. Confirm it exactly like TOTP, with factor_id and code. Email factors work the same way through /auth/mfa/email/enroll, defaulting to the account's own email if you do not pass one.

So how does Polaris actually send the text message or email? Through a pluggable port that you bind to Twilio, SES, SMTP, or anything else. That is the subject of part 5 (coming soon). Out of the box it ships dev drivers that simply log the code, so the whole flow works the moment you install it.

The login MFA gate

Here is where the loose end from part 2 gets tied off. When a user with a confirmed factor logs in, the password step does not open a session. Instead login returns a short-lived ticket:

{
  "data": {
    "mfa_required": true,
    "mfa_token": "eyJ0eXAiOiJKV1...",
    "factors": [
      { "id": "019ee088-083f-...", "type": "totp", "default": true }
    ]
  }
}

That mfa_token is a single-purpose JWT. It’s not an access token. It cannot read your data or hit any protected route (try it on /auth/me and you get a 401). The only endpoints that accept it are the gate's challenge and verify. For a TOTP or recovery code the client goes straight to verify. For SMS or email it first asks Polaris to send a code:

# sms/email factors only: send the code
curl -s https://api.example.com/v1/auth/mfa/challenge \
  -H 'Authorization: Bearer <mfa_token>' \
  -H 'Content-Type: application/json' \
  -d '{"factor_id":"019ee088-087f-..."}'
{ "data": { "channel": "sms", "destination": "+1 *** *** 0101" } }

Verify the code, and only then does Polaris mint a real session:

curl -s https://api.example.com/v1/auth/mfa/verify \
  -H 'Authorization: Bearer <mfa_token>' \
  -H 'Content-Type: application/json' \
  -d '{"factor_id":"019ee088-083f-...","code":"618402"}'
{
  "data": {
    "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1Ni...",
    "token_type": "Bearer",
    "expires_in": 900,
    "refresh_token": "cm90YXRpbmctcmVmcmVzaA..."
  }
}

The minted token carries mfa: true, amr: ["pwd","otp"], and a fresh auth_time. To verify with a recovery code instead, send the code with no factor_id. Clean, single path, no special casing in your frontend.

Step-up: recent proof for dangerous actions

Some actions can be a bit scary. Changing a password, deleting an organization, removing your last MFA factor. For these, a valid session is not enough. Polaris wants proof of a recent strong authentication, stamped via that auth_time claim.

If you call a step-up-gated route and your auth_time is stale, you get a precise, machine-readable nudge instead of a vague rejection:

{
  "error": "step_up_required",
  "message": "This operation requires a recent re-authentication.",
  "step_up": "/auth/mfa/step-up"
}

The response even includes a WWW-Authenticate: Bearer error="step_up_required" header. Your client sends a code through /auth/mfa/step-up/challenge, verifies at /auth/mfa/step-up, and gets a fresh access token for the same session with a new auth_time (no refresh-token rotation, no new device entry, just a re-stamp). Then it retries the original call and it goes through. The default freshness window is five minutes, and it is configurable.

Hygiene you do not have to think about

A few things Polaris quietly takes care of for you:

  • OTP codes are stored as keyed HMACs, never plaintext.
  • Single-use is enforced by an atomic conditional update, so a code cannot be spent twice even under a race.
  • Send quotas and resend cooldowns push back on OTP bombing.
  • Recovery codes can be regenerated (step-up gated), which retires the old batch and issues ten fresh ones.
  • Removing your last confirmed factor while MFA is enforced is refused with 409 last_factor_protected, so you cannot accidentally lock yourself out.

Recap

Three factor types. One verification flow. Recovery codes for the bad day, a login gate that never opens a session after the password step alone, and step-up authentication for the operations that deserve a second look. The MFA design notes, including the RFC 6238 details, are in the reference docs at polaris.univeros.io.

Next up: part 4 - One user, many orgs (coming soon) where identity becomes multi-tenant and we get into roles, permissions, and the authorization Gate.

The Polaris series

  • Meet Polaris
  • Logins that do not leak
  • Real MFA in an afternoon (You are here)
  • One user, many orgs: multi-tenant RBAC (coming soon)
  • Bring your own Twilio, SES, and listeners (coming soon)

Build captivating apps and sophisticated B2B platforms

Stunning solutions for web, mobile, or cross-platform applications.

Learn more

Don't miss out on
our latest insights
– Subscribe Now!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Share This Post
Back to Blog
Don't miss out on
our latest insights
– Subscribe Now!
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Navigate
Start Now