Skip to content

Require two-factor authentication

This guide makes everyone who signs in to your app enter a one-time code as well as their password.

That’s two-factor authentication: the code is six digits from an authenticator app on the user’s phone, such as Google Authenticator, Microsoft Authenticator or FreeOTP, and it changes every 30 seconds. How strongly a user must sign in to a client is the client’s ACR level, and the strongest one asks for a code at every sign-in that starts a new session.

  1. In the admin console, open your client under Admin, Clients. On the Settings tab, set Default ACR level to Level 3: password and a one-time code, always, which is urn:goiabada:level2_mandatory, and click Save. Sign-ins that start after the change use it.

  2. Sign in to your app as a user with no authenticator. After the password, the sign-in page asks you to set one up: scan the QR code with an authenticator app, then enter the code it shows. From then on, a sign-in that starts a new session asks for your password and then a code. While your session lasts, it’s reused: signing in to your app again, or to another app at the same level, asks for neither. A request that asks for a fresh sign-in, with prompt=login, asks for both, whatever the session already gave. See Users who are already signed in.

  3. In your app, check how the user signed in before you let them in. The ID token says it in two claims, acr, the level, and amr, the methods the user used:

    {
    "acr": "urn:goiabada:level2_mandatory",
    "amr": ["pwd", "otp"]
    }

    Accept the sign-in only when amr has otp. The client’s setting already makes the auth server ask for the code, so this check is what keeps your app safe if someone lowers that setting later.

  4. If your app calls an API of yours, the access token carries the same acr and amr, so the API can make the same check. See Protect an API.

To ask for a code only before one sensitive operation, such as a payment, leave the client’s level as it is and send acr_values=urn:goiabada:level2_mandatory on that one authorization request. ACR and AMR shows how. It asks for a level, not for a new code: a user whose session already reached that level isn’t asked again. To require a recent sign-in as well, add max_age, as Sessions explains.

The admin console signs administrators in through its own client, admin-console-client, so the same setting makes every administrator use a code.

  1. Set up an authenticator for yourself first, under Account, Two-factor authentication. That way you know it works before it’s required.

  2. Open Admin, Clients, admin-console-client, and on the Settings tab set Default ACR level to Level 3: password and a one-time code, always, then click Save. Only an administrator with authserver:manage can change this client.

  3. The next time each administrator signs in to the admin console, they’re asked for a code, and one without an authenticator sets one up first.

A user can set up an authenticator before any client requires one, in the admin console under Account, Two-factor authentication: they scan the QR code, enter their password and the code the app shows, and click Turn on two-factor authentication. A client at level 2, the level new clients start with, then asks them for a code too.

At level 3, a user with no authenticator sets one up during the sign-in, on the page that asks for the code. Nothing is stored if their two-factor settings changed while they were doing it, for example in another tab: the sign-in ends, and they sign in again. See ACR and AMR.

The authenticator app lists the account under the auth server’s App name and the user’s email address.

The code is a time-based one-time password (RFC 6238): six digits, a new one every 30 seconds. The auth server also accepts the code from the 30 seconds before and after, so a phone whose clock is a little off still works.

Each code works once. Sent again, it’s refused, and the audit log records otp_code_replay_detected. Each user gets 5 wrong codes in 15 minutes, and then waits. See Too many attempts or 429.

100 wrong codes in a row, with no right one between them, disable the user’s authenticator. From then on every code is refused, the right one included, and the audit log records otp_authenticator_disabled. Someone who knew the user’s password, or held one of their sessions, sent those codes, so treat the password as known. The user’s Authentication tab in the admin console says the authenticator is disabled, and A user who lost their authenticator is the way back.

A user whose session already reached the client’s level isn’t asked for anything: the session is reused, and the tokens say urn:goiabada:level2_mandatory and ["pwd", "otp"] from the sign-in that earned it.

prompt=login doesn’t reuse the session: the user signs in again with their password and a code, whatever their session already gave, and the tokens say ["pwd", "otp"] and auth_time of this sign-in. A session that has idled out, reached its maximum lifetime or fallen outside the request’s max_age doesn’t count either, and the user is asked for both.

A user who signed in to another app with a password alone isn’t asked for their password again. They’re asked only for the code, which is a step-up, and their session keeps the higher level from then on.

When a user sets up or removes an authenticator, each of their sessions is checked again for a second factor before it’s used for a level above urn:goiabada:level1: a user with an authenticator enters a code, and a user with none left is asked for nothing at level 2 and sets up a new one at level 3.

An administrator opens Admin, Users, the user, and their Authentication tab, turns off Enabled under Two-factor authentication, clicks Save, and confirms. At level 3, the user sets up a new authenticator at their next sign-in.

The same steps bring back a user whose authenticator was disabled after 100 wrong codes, with one more. Whoever sent the codes knew the password, so before clicking Save, the administrator also types a new one in New password on the same tab. The save sets the new password first, which signs the user out everywhere and revokes every refresh token they hold, and then turns two-factor authentication off. The user signs in with the new password and sets up a new authenticator.

Turning it off lowers each of the user’s sessions to what a password alone reaches, so no token issued from them afterwards claims a code. Nobody is signed out, though, and apps the user was already signed in to keep refreshing their tokens with the claims they had. If the device could be in someone else’s hands, also end the user’s sessions: on their Sessions tab, choose End session beside each one, and confirm. That signs every browser out and revokes the refresh tokens each session gave out. A session left open is enough to set up a new authenticator at a level 3 sign-in, since that asks only for what the session doesn’t already have. See Ending sessions.

A user who still has their authenticator can turn it off themselves, with their password, under Account, Two-factor authentication and Turn off two-factor authentication. At level 3, they then set up a new one at their next sign-in to your app.

  • A silent request (prompt=none) can’t show the code page. When a code is due, it gets interaction_required. See prompt.
  • The password grant (ROPC) can’t ask for a code, so it refuses every user who has an authenticator, and everyone at a level 3 client.
  • The client credentials flow has no user, so nothing asks for a code.