Nimbu Developer Docs
Other

Customer Two-Step Verification

Let customers protect their storefront account with a code from an authenticator app, and add the two templates your theme needs for it

Customers can protect their account with two-step verification (2FA): after their password, they enter a 6-digit code from an authenticator app such as Google Authenticator, Microsoft Authenticator or 1Password. When they turn it on, they also get 10 backup codes that each work once, for when they don't have their phone.

The site offers it, and each customer decides whether to turn it on. Nothing changes for customers who don't.

Turning it on for a site

Open Settings → Online Store in the Nimbu admin and set Two-factor authentication for customers to:

ValueEffect
Off (default)Customers cannot turn on 2FA.
OptionalCustomers can turn it on from their account page.

Below the setting, a theme check shows whether the active theme has customers/two_factor.liquid, customers/two_factor_setup.liquid and a link to /account/two_factor in its customer templates, layouts or snippets. The link check is only a hint: a URL built in Liquid or JavaScript is not detected.

Switching the setting back to Off only stops new enrollment. Customers who already turned on 2FA keep it and still need their code to sign in. They can also still create new backup codes or turn 2FA off themselves. The admin tells you how many customers keep it.

In Liquid, site.customer_two_factor is "optional" when customers can turn it on and "off" otherwise. The site settings API returns the same value as customer_two_factor.

What your theme needs

TemplateRouteNeeded
customers/two_factor_setup.liquid/account/two_factorRequired to offer 2FA. Without it, customers cannot turn it on: /account/two_factor follows the normal missing-template behaviour.
customers/two_factor.liquid/login/two_factorOptional. Without it, Nimbu shows a built-in code page with your site name (or invoice logo), in the customer's language.

Also link to the setup page from the account page, so customers can find it. Show the link while the site offers 2FA, and also to customers who already use it, so they can still turn it off after the site setting goes back to Off:

{% if site.customer_two_factor == 'optional' or customer.two_factor_enabled %}
  <a href="/account/two_factor">
    Two-step verification ({% if customer.two_factor_enabled %}on{% else %}off{% endif %})
  </a>
{% endif %}

The starter themes include both templates and this link. The examples below are trimmed versions of them.

How signing in works

  1. The customer submits the login form with the right password.
  2. If they use 2FA and this browser is not trusted, they are not signed in yet. Nimbu redirects them to /login/two_factor.
  3. They enter the code from their app, or a backup code. They are signed in and go where the login would have sent them (the stored return location or redirect_after_login). A guest cart and the login form's "remember me" choice carry over.

The code step must be completed within 5 minutes, in the same browser and on the same site. Otherwise the customer is sent back to /login with a message. A wrong password gives the same error as before, so it never reveals whether an account uses 2FA. After 5 wrong codes, the account is locked for 15 minutes.

With Trust this browser, the customer skips the code in that browser for 30 days, on this site only. Trusted browsers are forgotten when the customer turns 2FA off, changes their password, or when staff reset their 2FA.

The code step also follows the checkout login, setting a new password through a reset link, an account activation link and ?autologin= links. Logins through an external provider ({% login_with %}) and JWT single sign-on don't ask for a code: the provider already checked the customer.

The code page: customers/two_factor.liquid

Variables

VariableDescription
two_factor.emailThe customer's email, masked: j***@example.com.
two_factor.trust_daysHow long "trust this browser" lasts: 30.
two_factor.locked_untilWhen the lockout ends, after too many wrong codes. Empty otherwise.
two_factor.backup_codes_remainingNumber of unused backup codes.
two_factor.cancel_url/login/two_factor/cancel: ends the attempt and goes back to /login.
two_factor.backup_code_attempttrue when the last attempt was a wrong backup code. Use it to open and focus the backup code form.
form_url/login/two_factor
page.title"Two-step verification", from the translation customer_two_factor.challenge.title.
flash.error / flash.successA wrong code, a lockout, or (after a password reset) the confirmation that the new password was saved.

{% form two_factor %}

Posts to /login/two_factor with the CSRF token. Use one form for the app code and a second one for a backup code:

FieldValue
two_factor[token]The 6-digit code from the app.
two_factor[use_backup_code]1 to check two_factor[backup_token] instead of two_factor[token].
two_factor[backup_token]A backup code.
two_factor[trust_device]1 to trust this browser for two_factor.trust_days days.

The fields also work without the two_factor[...] prefix (token, backup_token, ...).

Example

<h1>{{ page.title }}</h1>
<p>Signing in as {{ two_factor.email | escape }}. Enter the 6-digit code from your authenticator app.</p>

{% if flash.success %}<p class="success">{{ flash.success | escape }}</p>{% endif %}

{% if two_factor.locked_until %}
  <p class="error">Too many attempts. Try again after {{ two_factor.locked_until | date: "%H:%M" }}.</p>
  <a href="{{ two_factor.cancel_url }}">Back to the login page</a>
{% else %}
  {% if flash.error %}<p class="error">{{ flash.error | escape }}</p>{% endif %}

  {% form two_factor %}
    <label for="two_factor_token">Verification code</label>
    <input type="text" name="two_factor[token]" id="two_factor_token"
           inputmode="numeric" autocomplete="one-time-code" maxlength="8" required
           {% unless two_factor.backup_code_attempt %}autofocus{% endunless %}>

    <label>
      <input type="checkbox" name="two_factor[trust_device]" value="1">
      Trust this browser for {{ two_factor.trust_days }} days
    </label>

    <button type="submit">Verify</button>
    <a href="{{ two_factor.cancel_url }}">Cancel</a>
  {% endform %}

  <details {% if two_factor.backup_code_attempt %}open{% endif %}>
    <summary>Lost your phone? Use a backup code</summary>

    {% form two_factor %}
      <input type="hidden" name="two_factor[use_backup_code]" value="1">
      <label for="two_factor_backup_token">Backup code</label>
      <input type="text" name="two_factor[backup_token]" id="two_factor_backup_token"
             autocomplete="off" autocapitalize="off" spellcheck="false" required
             {% if two_factor.backup_code_attempt %}autofocus{% endif %}>

      <label>
        <input type="checkbox" name="two_factor[trust_device]" value="1">
        Trust this browser for {{ two_factor.trust_days }} days
      </label>

      <button type="submit">Use backup code</button>
    {% endform %}
  </details>
{% endif %}

The setup page: customers/two_factor_setup.liquid

Only signed-in customers can open /account/two_factor. Others go to the login page first and come back after signing in.

Variables

VariableDescription
two_factor.stateWhich step to show: disabled, setup, codes or enabled. See below.
two_factor.availabletrue when the site offers 2FA, so the customer can turn it on.
two_factor.password_required_but_missingtrue when the account has no password of its own (it was created without one, or by a social or single sign-on login). Such a customer must set a password through "forgot password" first.
two_factor.issuerThe name the authenticator app shows for the account: the site name.
two_factor.qr_code_svgThe QR code to scan, as inline SVG. Only in the setup state.
two_factor.secretThe same key as text in groups of four (ABCD EFGH ...), for typing into the app. Only in the setup state.
two_factor.setup_id / two_factor.setup_authIdentify the setup in progress. {% form two_factor_confirm %} adds them as hidden fields for you.
two_factor.backup_codesThe new backup codes. Only in the codes state, and shown only this once.
two_factor.backup_codes_remainingUnused backup codes (0 when 2FA is off).
two_factor.enabled_atWhen the customer turned 2FA on.
page.title"Two-step verification", from the translation customer_two_factor.setup.title.
flash.error / flash.successThe result of the last step. See Messages.

States and forms

StateWhat to showFormFields
disabledA button to start, when two_factor.available{% form two_factor_start %} posts to /account/two_factor/starttwo_factor[password]: the current password
setupThe QR code, the text key and a code field{% form two_factor_confirm %} posts to /account/two_factor/confirmtwo_factor[token]: a code from the app
codesThe backup codes, once--
enabledStatus, plus forms for new backup codes and to turn 2FA off{% form two_factor_backup_codes %} posts to /account/two_factor/backup_codes; {% form two_factor_disable %} posts to /account/two_factor/disabletwo_factor[password]

What each step does:

  • Start checks the password and shows the QR code. Starting is refused when the site does not offer 2FA, or when the customer already uses it. The QR code is valid for 10 minutes.
  • Confirm turns 2FA on and shows the backup codes. A wrong code shows the same QR code again with an error, so the customer doesn't have to scan again.
  • New backup codes replaces all backup codes and shows the new ones. The old codes stop working. This works even when the site setting is Off.
  • Turn off checks the password, turns 2FA off and redirects to /account/two_factor with a message. This also works when the site setting is Off.

Turning 2FA on or off signs the customer out everywhere else: other browsers, "remember me" cookies, API sessions, OAuth apps and autologin links. The browser they used stays signed in. Each step allows only a few tries (3, or 5 for the code) per 5 minutes; after that it redirects to /account/two_factor with the too_many_attempts message.

These pages are sent with Cache-Control: no-store, and a {% cache %} block in this template never stores the QR code, key or backup codes.

Example

<h1>{{ page.title }}</h1>

{% if flash.error %}<p class="error">{{ flash.error | escape }}</p>{% endif %}
{% if flash.success %}<p class="success">{{ flash.success | escape }}</p>{% endif %}

{% case two_factor.state %}
{% when 'setup' %}
  <p>Scan this QR code with an authenticator app.</p>
  {{ two_factor.qr_code_svg }}
  <p>Can't scan it? Enter this key instead: <code>{{ two_factor.secret }}</code></p>

  {% form two_factor_confirm %}
    <label for="two_factor_token">Verification code</label>
    <input type="text" name="two_factor[token]" id="two_factor_token"
           inputmode="numeric" autocomplete="one-time-code" maxlength="8" required autofocus>
    <button type="submit">Turn on</button>
    <a href="/account/two_factor">Cancel</a>
  {% endform %}

{% when 'codes' %}
  <p>Save these backup codes somewhere safe. Each one signs you in once. They are shown only this time.</p>
  <ul>
    {% for code in two_factor.backup_codes %}
      <li><code>{{ code }}</code></li>
    {% endfor %}
  </ul>
  <a href="/account">I saved my codes</a>

{% when 'enabled' %}
  <p>
    Two-step verification is on{% if two_factor.enabled_at %} since {{ two_factor.enabled_at | date: "%b %d, %Y" }}{% endif %}.
    You have {{ two_factor.backup_codes_remaining }} backup codes left.
  </p>

  <h2>New backup codes</h2>
  {% form two_factor_backup_codes %}
    <label for="codes_password">Password</label>
    <input type="password" name="two_factor[password]" id="codes_password" autocomplete="current-password" required>
    <button type="submit">Get new codes</button>
  {% endform %}

  <h2>Turn off</h2>
  {% form two_factor_disable %}
    <label for="disable_password">Password</label>
    <input type="password" name="two_factor[password]" id="disable_password" autocomplete="current-password" required>
    <button type="submit">Turn off</button>
  {% endform %}

{% else %}
  {% if two_factor.password_required_but_missing %}
    <p>
      Your account has no password yet.
      <a href="/logout?return_to=/password/new">Sign out and request a password link</a>,
      set your password, then come back here.
    </p>
  {% elsif two_factor.available %}
    <p>Protect your account with a code from your phone on top of your password.</p>
    {% form two_factor_start %}
      <label for="start_password">Password</label>
      <input type="password" name="two_factor[password]" id="start_password" autocomplete="current-password" required>
      <button type="submit">Set up</button>
    {% endform %}
  {% else %}
    <p>Two-step verification is not available for this shop.</p>
  {% endif %}
{% endcase %}

Messages

Both pages show their messages through flash.error and flash.success. The texts are translations under customer_two_factor.*, so you can change them per site in your translations:

KeyDefault (English)
customer_two_factor.errors.invalid_codeThat code is not valid. Check your authenticator app and try again.
customer_two_factor.errors.invalid_backup_codeThat backup code is not valid or was already used.
customer_two_factor.errors.locked_outToo many failed attempts. Please try again in 15 minutes.
customer_two_factor.errors.expiredYour sign-in expired. Please sign in again.
customer_two_factor.errors.unavailableVerification is temporarily unavailable. Please try again in a moment.
customer_two_factor.errors.password_invalidThat password is not correct.
customer_two_factor.errors.password_invalid_maybe_generatedThe same, with a hint to set a password through "forgot password" (shown when the account may have been created by a social or single sign-on login).
customer_two_factor.errors.password_missingYour account has no password yet. ...
customer_two_factor.errors.setup_expiredThe setup expired. Please start again.
customer_two_factor.errors.too_many_attemptsToo many attempts. Please wait a few minutes and try again.
customer_two_factor.errors.not_availableTwo-step verification is not available on this site.
customer_two_factor.errors.already_enabledTwo-step verification is already on for your account.
customer_two_factor.errors.not_enabledTwo-step verification is not on for your account.
customer_two_factor.setup.enabledTwo-step verification is on. Save your backup codes now: they are shown only once.
customer_two_factor.setup.backup_codes_regeneratedNew backup codes were created. Your old codes no longer work.
customer_two_factor.setup.disabledTwo-step verification is off.

After a successful code, the customer lands on the next page with the usual "signed in" message (flash.success). The built-in code page uses Nimbu's own texts and ignores these translations.

AJAX login forms

A login form that posts with Accept: application/json gets a new answer when the customer uses 2FA: status 200, like a successful login, but with two_factor_required and the code page as redirect_to. The customer is not signed in yet.

SituationStatusBody
Signed in200{ "success": "...", "redirect_to": "..." }
Password right, code needed200{ "two_factor_required": true, "redirect_to": "/login/two_factor" }
Wrong email or password401{ "error": "..." }
Code check temporarily unavailable503{ "error": "..." }

Setting a new password through a reset link answers the same way when a code is needed: { "success": "...", "two_factor_required": true, "redirect_to": "/login/two_factor" }.

If your AJAX login only looks at the status code, it will treat two_factor_required as a completed login. Check the flag first:

const response = await fetch(form.action, {
  method: 'POST',
  body: new FormData(form),
  headers: { 'Accept': 'application/json' }
});
const data = await response.json();

if (response.ok && data.two_factor_required) {
  window.location = data.redirect_to; // the code page
} else if (response.ok) {
  window.location = data.redirect_to; // signed in
} else {
  showError(data.error);
}

You can also post the code with AJAX from your own form, using {% form two_factor %} for the CSRF token. POST /login/two_factor with Accept: application/json answers:

SituationStatusBody
Signed in200{ "success": "...", "redirect_to": "..." }
Wrong code or locked out422{ "error": "...", "locked_until": "..." } (locked_until, an ISO 8601 time, only when locked out)
Attempt expired or refused401{ "error": "...", "redirect_to": "/login" }

Emails

Three notifications go with customer 2FA. Edit them under Settings → Notifications. They show up there once the site offers 2FA, and stay as long as any customer still uses it.

NotificationSent whenExtra variables
two_factor_enabledThe customer turned on 2FA.-
two_factor_disabledThe customer turned off 2FA, or staff reset it.reason: customer or admin_reset
backup_codes_lowA sign-in with a backup code left the customer with 3 codes. Sent once, not on every later backup code.backup_codes_remaining

All three also get customer, site and the other variables every notification has (locale, now, today, channels, ...). For example, in two_factor_disabled:

Dear {{ customer.name }},

{% if reason == "admin_reset" %}
The {{ site.name }} team turned off two-step verification for your account.
{% else %}
Two-step verification is now off for your account at {{ site.name }}.
{% endif %}

If you did not ask for this, reset your password right away and contact us.

Resetting a customer's 2FA

A customer who lost both their phone and their backup codes cannot sign in. Staff who may edit customers can turn 2FA off for them: open the customer in the admin and click Reset 2FA in the two-factor authentication section.

A reason is required. It is only kept in the Activity log, not sent to the customer. The reset turns 2FA off, forgets the customer's trusted browsers and signs them out everywhere. The customer gets the two_factor_disabled email with reason set to admin_reset, and can turn 2FA on again afterwards while the site offers it.

On this page