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:
| Value | Effect |
|---|---|
| Off (default) | Customers cannot turn on 2FA. |
| Optional | Customers 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
| Template | Route | Needed |
|---|---|---|
customers/two_factor_setup.liquid | /account/two_factor | Required 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_factor | Optional. 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
- The customer submits the login form with the right password.
- If they use 2FA and this browser is not trusted, they are not signed in yet. Nimbu redirects them to
/login/two_factor. - 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
| Variable | Description |
|---|---|
two_factor.email | The customer's email, masked: j***@example.com. |
two_factor.trust_days | How long "trust this browser" lasts: 30. |
two_factor.locked_until | When the lockout ends, after too many wrong codes. Empty otherwise. |
two_factor.backup_codes_remaining | Number of unused backup codes. |
two_factor.cancel_url | /login/two_factor/cancel: ends the attempt and goes back to /login. |
two_factor.backup_code_attempt | true 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.success | A 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:
| Field | Value |
|---|---|
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
| Variable | Description |
|---|---|
two_factor.state | Which step to show: disabled, setup, codes or enabled. See below. |
two_factor.available | true when the site offers 2FA, so the customer can turn it on. |
two_factor.password_required_but_missing | true 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.issuer | The name the authenticator app shows for the account: the site name. |
two_factor.qr_code_svg | The QR code to scan, as inline SVG. Only in the setup state. |
two_factor.secret | The 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_auth | Identify the setup in progress. {% form two_factor_confirm %} adds them as hidden fields for you. |
two_factor.backup_codes | The new backup codes. Only in the codes state, and shown only this once. |
two_factor.backup_codes_remaining | Unused backup codes (0 when 2FA is off). |
two_factor.enabled_at | When the customer turned 2FA on. |
page.title | "Two-step verification", from the translation customer_two_factor.setup.title. |
flash.error / flash.success | The result of the last step. See Messages. |
States and forms
| State | What to show | Form | Fields |
|---|---|---|---|
disabled | A button to start, when two_factor.available | {% form two_factor_start %} posts to /account/two_factor/start | two_factor[password]: the current password |
setup | The QR code, the text key and a code field | {% form two_factor_confirm %} posts to /account/two_factor/confirm | two_factor[token]: a code from the app |
codes | The backup codes, once | - | - |
enabled | Status, 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/disable | two_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_factorwith 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:
| Key | Default (English) |
|---|---|
customer_two_factor.errors.invalid_code | That code is not valid. Check your authenticator app and try again. |
customer_two_factor.errors.invalid_backup_code | That backup code is not valid or was already used. |
customer_two_factor.errors.locked_out | Too many failed attempts. Please try again in 15 minutes. |
customer_two_factor.errors.expired | Your sign-in expired. Please sign in again. |
customer_two_factor.errors.unavailable | Verification is temporarily unavailable. Please try again in a moment. |
customer_two_factor.errors.password_invalid | That password is not correct. |
customer_two_factor.errors.password_invalid_maybe_generated | The 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_missing | Your account has no password yet. ... |
customer_two_factor.errors.setup_expired | The setup expired. Please start again. |
customer_two_factor.errors.too_many_attempts | Too many attempts. Please wait a few minutes and try again. |
customer_two_factor.errors.not_available | Two-step verification is not available on this site. |
customer_two_factor.errors.already_enabled | Two-step verification is already on for your account. |
customer_two_factor.errors.not_enabled | Two-step verification is not on for your account. |
customer_two_factor.setup.enabled | Two-step verification is on. Save your backup codes now: they are shown only once. |
customer_two_factor.setup.backup_codes_regenerated | New backup codes were created. Your old codes no longer work. |
customer_two_factor.setup.disabled | Two-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.
| Situation | Status | Body |
|---|---|---|
| Signed in | 200 | { "success": "...", "redirect_to": "..." } |
| Password right, code needed | 200 | { "two_factor_required": true, "redirect_to": "/login/two_factor" } |
| Wrong email or password | 401 | { "error": "..." } |
| Code check temporarily unavailable | 503 | { "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:
| Situation | Status | Body |
|---|---|---|
| Signed in | 200 | { "success": "...", "redirect_to": "..." } |
| Wrong code or locked out | 422 | { "error": "...", "locked_until": "..." } (locked_until, an ISO 8601 time, only when locked out) |
| Attempt expired or refused | 401 | { "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.
| Notification | Sent when | Extra variables |
|---|---|---|
two_factor_enabled | The customer turned on 2FA. | - |
two_factor_disabled | The customer turned off 2FA, or staff reset it. | reason: customer or admin_reset |
backup_codes_low | A 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.
Related
- Customer Login - External login providers and single sign-on
- Templates - Template types and routes
- Global Variables - The
customerandsitedrops - Authentication - Sending the code with API logins
Customer Login with External Providers
Let customers sign in with Microsoft, Facebook, SAML, another Nimbu site or JWT SSO, and build the OAuth2 consent screen when your site is the identity provider
SEO, robots.txt & llms.txt
What Nimbu does for search engines and AI agents automatically, and how a theme can add robots.txt rules, language annotations and llms.txt content