MFA spans three modules, and you will usually need all three:
Auth0Client.Management.Guardian | tenant-wide configuration — which factors exist, how they are delivered |
Auth0Client.Authentication.Mfa | the end user's own authenticators, and challenging them at login |
Auth0Client.Authentication.Token | exchanging a verified challenge for tokens |
Assumes you already have a working login — see Logging users in.
Auth0 reference: Guardian, MFA endpoints.
Configuring the tenant
alias Auth0Client.Management.Guardian
Guardian.factors()
#=> {:ok, [%{"name" => "otp", "enabled" => false}, ...]}
Guardian.update_factor("otp", %{enabled: true})
Guardian.update_policies(["all-applications"]) # a bare list, not a mapupdate_factor/2 and update_policies/1 take effect immediately for every user in the tenant.
For providers offering both verbs, update_* merges (PATCH) and replace_* overwrites the whole
configuration (PUT):
Guardian.update_apns(%{sandbox: true}) # merge
Guardian.replace_apns(%{bundle_id: "com.example.app", ...}) # overwriteAuth0 exposes no way to read the FCM configuration back — update_fcm/1 and replace_fcm/1 exist, but
there is no fcm/0. That asymmetry with APNs and SNS is Auth0's, not an omission here.
Enrolling a user
Out of band, as an administrator — hand the user the ticket_url, or let Auth0 email it:
{:ok, %{"ticket_url" => url}} =
Guardian.create_enrollment_ticket(%{user_id: "auth0|abc123", send_mail: false})Or from the user's own session, with Auth0Client.Authentication.Mfa:
alias Auth0Client.Authentication.Mfa
Mfa.associate(access_token, %{authenticator_types: ["otp"]})
Mfa.authenticators(access_token)
Mfa.delete_authenticator(access_token, "totp|dev_abc123")associate/2 normally needs an access token with the enroll scope and audience
https://{your_domain}/mfa/. A user with no factors yet cannot obtain one, so Auth0 lets you pass the
mfa_token from an mfa_required error instead — which is what makes first-time enrolment possible.
The response carries recovery codes once, on the first authenticator only. Show them to the user then; they cannot be retrieved again.
To inspect or reset enrolment as an administrator:
Auth0Client.Management.User.enrollments("auth0|abc123")
Auth0Client.Management.User.delete_authenticators("auth0|abc123")enrollments/1 returns the first confirmed enrolment, not every one the user has.
Completing an MFA login
When the tenant requires MFA, the password grant does not fail — it returns a 403 carrying an
mfa_token, which you exchange after challenging the user. A caller matching only on {:ok, _} never
sees it, so the flow starts from the error:
alias Auth0Client.Authentication.{Mfa, Token}
{:error, %Auth0Client.Error{body: %{"error" => "mfa_required", "mfa_token" => mfa_token}}} =
Token.password(client_id, "user@example.com", password)
# Challenge the user; for an authenticator app this needs no delivery step
{:ok, _} = Mfa.challenge(%{mfa_token: mfa_token, client_id: client_id})
# Exchange the code they read off their app
{:ok, tokens} = Token.verify_otp(client_id, mfa_token, "123456")Out-of-band factors
Push, SMS and voice return an oob_code from the challenge instead:
{:ok, %{"oob_code" => oob_code, "binding_method" => binding}} =
Mfa.challenge(%{mfa_token: mfa_token, client_id: client_id, challenge_type: "oob"})
# binding_method "prompt" means the user must also type a short code
Token.verify_oob(client_id, mfa_token, oob_code, %{binding_code: "1234"})Recovery codes
A user who has lost their device uses a recovery code.
{:ok, %{"recovery_code" => new_code}} =
Token.verify_recovery_code(client_id, mfa_token, old_code)The response carries a replacement
The recovery code just used is spent. Show new_code to the user and have them store it — dropping it
silently leaves them with no way back in.