Configure Authentication
Multi-Factor Authentication
Add TOTP-based two-factor authentication with authenticator apps and backup codes.
Multi-factor authentication (MFA) adds a second layer of security to your users' accounts. After entering their password, users must provide a time-based one-time password (TOTP) generated by an authenticator app like Google Authenticator, Authy, 1Password, or Bitwarden.
This guide walks you through enabling MFA, integrating it into your app, and handling the full lifecycle from setup to sign-in challenges.
What is TOTP-Based MFA?
TOTP (Time-based One-Time Password) is the industry-standard algorithm behind most authenticator apps. Here is how it works:
- Your app generates a unique secret key for the user.
- The user adds that secret to their authenticator app, usually by scanning a QR code.
- The authenticator app generates a new 6-digit code every 30 seconds based on the shared secret.
- During sign-in, the user enters the current code to prove they have access to the authenticator device.
Because the codes rotate every 30 seconds and require the physical device, TOTP provides strong protection against credential theft, phishing, and replay attacks.
Enabling MFA
Step 1: Turn It On in the Dashboard
- Open your project in the Banata Auth Dashboard.
- Navigate to Authentication > Methods.
- Find Two-Factor Authentication (TOTP) and toggle it on.
- Save your changes.
That is all you need on the server side. The two-factor plugin activates automatically for your project.
Step 2: Add It to Your Client
Once MFA is enabled in the dashboard, you can use the twoFactor methods on your auth client. No additional SDK configuration is required beyond your standard auth client setup:
import { authClient } from "@/lib/auth-client";
// The twoFactor methods are available automatically
// when MFA is enabled in your dashboard.
authClient.twoFactor.enable({ password });
authClient.twoFactor.verifyTotp({ code });
authClient.twoFactor.disable({ password });How the Setup Flow Works
When a user decides to enable MFA on their account, the flow looks like this:
- The user navigates to their security settings and clicks "Enable Two-Factor Authentication."
- Your app prompts the user to re-enter their current password for verification.
- Your app calls
authClient.twoFactor.enable({ password }). - The server generates a TOTP secret and returns a
totpURI(for the QR code) andbackupCodes. - Your app displays the QR code for the user to scan with their authenticator app.
- The user scans the QR code and enters the 6-digit code the app generates.
- Your app calls
authClient.twoFactor.verifyTotp({ code })to confirm setup. - Your app displays the backup codes for the user to save in a secure location.
Client API
Enable Two-Factor Authentication
Call authClient.twoFactor.enable() to start MFA setup. You must pass the user's current password for re-authentication:
import { authClient } from "@/lib/auth-client";
const { data, error } = await authClient.twoFactor.enable({
password: "currentPassword",
});
if (error) {
console.error("Failed to enable 2FA:", error.message);
return;
}
// data.totpURI — Use this to generate a QR code for the user
// data.backupCodes — One-time recovery codes (display these once!)
console.log("TOTP URI:", data.totpURI);
console.log("Backup codes:", data.backupCodes);Verify a TOTP Code
After the user scans the QR code, have them enter a 6-digit code from their authenticator app to confirm everything is working:
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.twoFactor.verifyTotp({
code: "123456", // 6-digit code from the authenticator app
});
if (error) {
console.error("Invalid code. Please try again.");
} else {
console.log("Two-factor authentication verified and enabled!");
}Disable Two-Factor Authentication
To let users turn off MFA, call authClient.twoFactor.disable() with their current password:
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.twoFactor.disable({
password: "currentPassword",
});
if (!error) {
console.log("Two-factor authentication has been disabled.");
}Generating a QR Code
The totpURI returned by authClient.twoFactor.enable() is a standard otpauth:// URI. You can render it as a QR code using any QR library. Here is an example with qrcode.react:
npm install qrcode.reactimport QRCode from "qrcode.react";
function TotpQRCode({ totpURI }: { totpURI: string }) {
// Extract the manual entry key from the URI
const manualKey = new URL(totpURI).searchParams.get("secret");
return (
<div>
<h3>Scan this QR code with your authenticator app</h3>
<QRCode value={totpURI} size={200} />
<p>
Can't scan? Enter this key manually:{" "}
<code>{manualKey}</code>
</p>
</div>
);
}The QR code encodes the shared secret, your app name, and the user's account identifier so the authenticator app can label the entry correctly.
Backup Codes
When MFA is enabled, backup codes are generated automatically. These are emergency recovery codes your users can use if they lose access to their authenticator app.
Backup Code Format
| Property | Details |
|---|---|
| Length | 8 characters per code |
| Characters | Uppercase alphanumeric (A-Z, 0-9) |
| Count | 10 codes generated |
| Usage | Single-use — each code is consumed after one successful sign-in |
Displaying Backup Codes
Show backup codes exactly once, immediately after MFA setup. Give your users a clear way to copy or save them:
function BackupCodes({ codes }: { codes: string[] }) {
function handleCopy() {
navigator.clipboard.writeText(codes.join("\n"));
}
return (
<div>
<h3>Save Your Backup Codes</h3>
<p>
Store these codes somewhere safe. Each code can only be used once
to sign in if you lose access to your authenticator app.
</p>
<pre
style={{
fontFamily: "monospace",
backgroundColor: "#f5f5f5",
padding: "16px",
borderRadius: "8px",
}}
>
{codes.join("\n")}
</pre>
<button onClick={handleCopy}>Copy to Clipboard</button>
</div>
);
}Important: Backup codes are only returned when MFA is first enabled. If a user loses their codes, they will need to disable and re-enable MFA to generate a fresh set.
Signing In with a Backup Code
During an MFA challenge, if the user cannot access their authenticator app, they can use a backup code instead:
import { authClient } from "@/lib/auth-client";
const { error } = await authClient.twoFactor.verifyBackupCode({
code: "AB12CD34", // One of the saved backup codes
});
if (!error) {
// Signed in successfully. This backup code is now consumed.
console.log("Signed in with backup code.");
}Complete MFA Setup Component
Here is a full React component that walks users through the entire MFA setup process in four steps: enter password, scan QR code, verify code, and save backup codes.
"use client";
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
import QRCode from "qrcode.react";
type Step = "password" | "scan" | "verify" | "backup" | "done";
export function MFASetup() {
const [step, setStep] = useState<Step>("password");
const [password, setPassword] = useState("");
const [totpURI, setTotpURI] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleEnable() {
setError("");
setLoading(true);
const { data, error } = await authClient.twoFactor.enable({
password,
});
setLoading(false);
if (error) {
setError(error.message ?? "Failed to enable two-factor authentication.");
return;
}
setTotpURI(data.totpURI);
setBackupCodes(data.backupCodes ?? []);
setStep("scan");
}
async function handleVerify() {
setError("");
setLoading(true);
const { error } = await authClient.twoFactor.verifyTotp({ code });
setLoading(false);
if (error) {
setError("Invalid code. Check your authenticator app and try again.");
return;
}
setStep("backup");
}
if (step === "password") {
return (
<div>
<h2>Enable Two-Factor Authentication</h2>
<p>Enter your current password to get started.</p>
<input
type="password"
placeholder="Current password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleEnable} disabled={loading}>
{loading ? "Setting up..." : "Continue"}
</button>
</div>
);
}
if (step === "scan") {
return (
<div>
<h2>Scan QR Code</h2>
<p>
Open your authenticator app and scan this QR code to add your
account.
</p>
<QRCode value={totpURI} size={200} />
<p>
Or enter this key manually:{" "}
<code>{new URL(totpURI).searchParams.get("secret")}</code>
</p>
<button onClick={() => setStep("verify")}>
I've scanned the code
</button>
</div>
);
}
if (step === "verify") {
return (
<div>
<h2>Verify Your Authenticator</h2>
<p>Enter the 6-digit code shown in your authenticator app.</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
style={{
fontSize: "24px",
letterSpacing: "8px",
textAlign: "center",
width: "200px",
}}
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleVerify} disabled={loading}>
{loading ? "Verifying..." : "Verify"}
</button>
</div>
);
}
if (step === "backup") {
return (
<div>
<h2>Save Your Backup Codes</h2>
<p>
Write these down or store them in a password manager. Each code
can only be used once if you lose access to your authenticator app.
</p>
<pre
style={{
fontFamily: "monospace",
backgroundColor: "#f5f5f5",
padding: "16px",
borderRadius: "8px",
}}
>
{backupCodes.join("\n")}
</pre>
<button
onClick={() => navigator.clipboard.writeText(backupCodes.join("\n"))}
>
Copy Codes
</button>
<button onClick={() => setStep("done")}>
I've saved my backup codes
</button>
</div>
);
}
// step === "done"
return (
<div>
<h2>Two-Factor Authentication Enabled</h2>
<p>
Your account is now protected with two-factor authentication.
You will need to enter a code from your authenticator app each
time you sign in.
</p>
</div>
);
}MFA Challenge During Sign-In
When a user with MFA enabled signs in, the server returns a response indicating that a TOTP code is required before the session can be created. You need to detect this and show a second input step.
Detecting the MFA Challenge
After calling authClient.signIn.email(), check whether the response indicates an MFA challenge:
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (error?.status === 403 && error.message?.includes("two-factor")) {
// MFA is required — show the TOTP input
}Full Sign-In Component with MFA Support
"use client";
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
export function SignInWithMFA() {
const [step, setStep] = useState<"credentials" | "totp" | "backup">(
"credentials"
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn() {
setError("");
setLoading(true);
const { data, error } = await authClient.signIn.email({
email,
password,
});
setLoading(false);
if (error) {
if (error.status === 403 && error.message?.includes("two-factor")) {
setStep("totp");
return;
}
setError(error.message ?? "Sign in failed.");
return;
}
// No MFA required — sign-in complete
window.location.href = "/dashboard";
}
async function handleTOTP() {
setError("");
setLoading(true);
const { error } = await authClient.twoFactor.verifyTotp({ code });
setLoading(false);
if (error) {
setError("Invalid code. Please try again.");
return;
}
window.location.href = "/dashboard";
}
async function handleBackupCode() {
setError("");
setLoading(true);
const { error } = await authClient.twoFactor.verifyBackupCode({ code });
setLoading(false);
if (error) {
setError("Invalid backup code.");
return;
}
window.location.href = "/dashboard";
}
if (step === "totp") {
return (
<div>
<h2>Two-Factor Authentication</h2>
<p>Enter the 6-digit code from your authenticator app.</p>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleTOTP} disabled={loading}>
{loading ? "Verifying..." : "Verify"}
</button>
<button
onClick={() => {
setCode("");
setError("");
setStep("backup");
}}
>
Use a backup code instead
</button>
</div>
);
}
if (step === "backup") {
return (
<div>
<h2>Use a Backup Code</h2>
<p>Enter one of the backup codes you saved when setting up MFA.</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder="AB12CD34"
style={{ fontFamily: "monospace", letterSpacing: "4px" }}
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button onClick={handleBackupCode} disabled={loading}>
{loading ? "Verifying..." : "Sign In"}
</button>
<button
onClick={() => {
setCode("");
setError("");
setStep("totp");
}}
>
Use authenticator app instead
</button>
</div>
);
}
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleSignIn();
}}
>
<h2>Sign In</h2>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <p style={{ color: "red" }}>{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
);
}Security Considerations
Keep these best practices in mind when implementing MFA:
-
Always re-authenticate before changing MFA settings. Require the user's current password before enabling or disabling two-factor authentication. This prevents attackers who gain temporary access to a session from modifying MFA settings.
-
Show backup codes exactly once. Display backup codes immediately after MFA is enabled, and do not provide a way to retrieve them later. If a user loses their codes, they should disable and re-enable MFA to generate new ones.
-
Keep your server clock accurate. TOTP codes are time-sensitive and rotate every 30 seconds. If your server's clock drifts too far, valid codes will be rejected. Use NTP to keep your server synchronized.
-
Never store TOTP secrets on the client. The TOTP secret is stored server-side and is only exposed to the user during initial setup via the QR code. Your client application should never persist the secret.
-
Consider requiring MFA for sensitive operations. Beyond sign-in, you might want to prompt for a fresh TOTP code before letting users change their email address, delete their account, or manage API keys.
-
Plan for account recovery. Users who lose both their authenticator device and backup codes will be locked out. Consider building an admin recovery flow for your support team to assist these users.
Troubleshooting
"Invalid code" errors during setup
If the user scans the QR code but the verification code is rejected, check the following:
- Clock skew: The user's device clock may be out of sync. Most authenticator apps rely on accurate device time. On Android, Google Authenticator has a "Time correction for codes" option in settings.
- Stale code: TOTP codes rotate every 30 seconds. If the user is entering a code right as it expires, ask them to wait for the next one.
- Wrong account: If the user has multiple entries in their authenticator app, they may be reading the code from the wrong one.
Users locked out after losing their device
If a user loses access to their authenticator app and has no backup codes:
- Verify their identity through your support process.
- Use an admin flow to disable MFA on their account.
- Have the user sign in and set up MFA again with their new device.
MFA challenge not appearing during sign-in
If MFA-enabled users can sign in without being prompted for a code:
- Confirm that MFA is still enabled in the Dashboard > Authentication > Methods settings.
- Check that the user's account actually has MFA enabled (the setup flow must be completed, including the verification step).
Next Steps
- Organizations — Set up multi-tenant workspaces for your users.
- Roles & Permissions — Add fine-grained access control to your app.
- Webhooks — Get notified about authentication events including MFA changes.