Banata

Configure Authentication

Email OTP

Passwordless authentication using a 6-digit code sent to the user's email.

Email OTP lets your users sign in with a 6-digit numeric code sent to their email instead of a password or a magic link. The user enters their email, receives a code, and types it into your app. That is it -- no passwords to remember, no links to click.

This approach works especially well on mobile devices and avoids common issues with link-based authentication such as broken URLs and email clients consuming tokens on preview.


Enable Email OTP

From the Dashboard

  1. Open the Banata dashboard and select your project.
  2. Go to Authentication > Methods.
  3. Toggle Email OTP on.
  4. Make sure you have an active email provider configured under Emails > Providers (Resend, SendGrid, etc.) so that codes can actually be delivered.

Changes take effect immediately.

From the SDK

You can also enable Email OTP programmatically:

ts
import { BanataAuth } from "@banata-auth/sdk";
 
const banata = new BanataAuth({
  apiKey: process.env.BANATA_API_KEY!,
  baseUrl: "https://auth.banata.dev",
});
 
await banata.configuration.saveDashboardConfig({
  authMethods: {
    emailOtp: true,
  },
});

The SDK and dashboard share the same configuration, so a change in one is immediately visible in the other.


Client-Side API

Email OTP uses a two-step flow through a single method. The presence or absence of the otp parameter determines which step you are on.

Step 1 -- Send the Code

Call authClient.signIn.emailOtp with just the user's email. Banata generates a 6-digit code and emails it to the user.

ts
import { authClient } from "@/lib/auth-client";
 
const { error } = await authClient.signIn.emailOtp({
  email: "user@example.com",
});
 
if (error) {
  // Possible errors:
  // - "Rate limit exceeded" (429)
  // - "Invalid email" (422)
  console.error(error.message);
} else {
  // Code sent -- show the OTP input form
}

Step 2 -- Verify the Code

Call the same method again, this time including the otp the user entered. If the code is valid, Banata creates a session and returns the user.

ts
const { data, error } = await authClient.signIn.emailOtp({
  email: "user@example.com",
  otp: "123456",
});
 
if (error) {
  // Possible errors:
  // - "Invalid or expired OTP" (400)
  // - "Too many attempts" (429)
  console.error(error.message);
} else {
  // Authenticated -- data contains user and session
  console.log(data.user);
  console.log(data.session);
  window.location.href = "/dashboard";
}

Complete Form Example

Here is a full React component that walks the user through both steps: entering their email, then entering the code.

tsx
"use client";
 
import { authClient } from "@/lib/auth-client";
import { useState } from "react";
 
export default function EmailOtpSignIn() {
  const [email, setEmail] = useState("");
  const [otp, setOtp] = useState("");
  const [step, setStep] = useState<"email" | "code">("email");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);
 
  async function handleSendCode(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);
 
    const { error } = await authClient.signIn.emailOtp({ email });
 
    setLoading(false);
    if (error) {
      setError(error.message ?? "Failed to send code");
    } else {
      setStep("code");
    }
  }
 
  async function handleVerifyCode(e: React.FormEvent) {
    e.preventDefault();
    setError("");
    setLoading(true);
 
    const { data, error } = await authClient.signIn.emailOtp({ email, otp });
 
    setLoading(false);
    if (error) {
      setError(error.message ?? "Invalid code");
    } else {
      window.location.href = "/dashboard";
    }
  }
 
  if (step === "code") {
    return (
      <form onSubmit={handleVerifyCode}>
        <h1>Enter your code</h1>
        <p>We sent a 6-digit code to <strong>{email}</strong>.</p>
 
        <input
          type="text"
          inputMode="numeric"
          maxLength={6}
          placeholder="000000"
          value={otp}
          onChange={(e) => setOtp(e.target.value)}
          style={{
            fontSize: "24px",
            letterSpacing: "8px",
            textAlign: "center",
            width: "200px",
          }}
          autoFocus
          required
        />
 
        {error && <p style={{ color: "red" }}>{error}</p>}
 
        <button type="submit" disabled={loading}>
          {loading ? "Verifying..." : "Verify"}
        </button>
        <button type="button" onClick={() => { setStep("email"); setOtp(""); setError(""); }}>
          Use a different email
        </button>
      </form>
    );
  }
 
  return (
    <form onSubmit={handleSendCode}>
      <h1>Sign in with Email Code</h1>
 
      <input
        type="email"
        placeholder="you@example.com"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
 
      {error && <p style={{ color: "red" }}>{error}</p>}
 
      <button type="submit" disabled={loading}>
        {loading ? "Sending..." : "Send Code"}
      </button>
    </form>
  );
}

How It Works

Here is the full flow from start to finish:

  1. The user enters their email address in your app.
  2. Your client calls authClient.signIn.emailOtp({ email }).
  3. Banata generates a 6-digit numeric code and stores it with a 10-minute expiry.
  4. Banata sends the code to the user's email using the provider configured in your dashboard.
  5. The user opens their email and copies the code.
  6. Your client calls authClient.signIn.emailOtp({ email, otp }).
  7. Banata verifies the code matches and has not expired.
  8. If no account exists for that email, Banata creates a new user automatically.
  9. If the account already exists, Banata signs the user into the existing account.
  10. A session is created and returned to your client.

OTP Properties

PropertyValue
Length6 digits
Character setNumeric only (0--9)
Expiry10 minutes
UsageSingle-use -- consumed on successful verification
RegenerationRequesting a new code invalidates any previous code for that email

New Users vs. Existing Users

Email OTP handles both sign-in and sign-up in a single flow:

  • New email -- A new user account is created automatically when the code is verified. No separate sign-up step is needed.
  • Existing email -- The existing user is signed in. Their account data remains unchanged.
  • Linked accounts -- If the email matches a user who originally signed up with email/password or social OAuth, they are signed into that same account. Banata links by email address.

Combining with Other Methods

Email OTP works alongside any other authentication method. You can enable multiple methods in Authentication > Methods and let users choose.

A common pattern is offering a toggle between OTP and password sign-in:

tsx
"use client";
 
import { useState } from "react";
 
export default function SignInPage() {
  const [mode, setMode] = useState<"otp" | "password">("otp");
 
  return (
    <div>
      <div>
        <button onClick={() => setMode("otp")}>Email Code</button>
        <button onClick={() => setMode("password")}>Password</button>
      </div>
 
      {mode === "otp" ? <EmailOtpSignIn /> : <PasswordSignIn />}
    </div>
  );
}

You can also combine Email OTP with MFA. For example, a user signs in with an OTP code and then completes a TOTP challenge as a second factor.


Rate Limits

OTP endpoints are rate-limited to prevent abuse:

OperationLimitScope
Send OTP30 requests per minutePer IP address
Verify OTP10 attempts per minutePer email address

The verify limit is intentionally stricter. A 6-digit numeric code has 1,000,000 possible values, so limiting verification attempts is critical to prevent brute-force guessing.

When a rate limit is exceeded, the API returns a 429 status with a "Rate limit exceeded" message. Consider showing a countdown timer in your UI so users know when they can retry.


Security Considerations

  1. Short expiry window -- Codes expire after 10 minutes, limiting the window for interception.
  2. Single-use codes -- Each code is consumed on verification and cannot be reused.
  3. Strict verification rate limits -- The 10 attempts/minute cap makes brute-force attacks against the 6-digit code space impractical.
  4. Automatic invalidation -- Requesting a new code invalidates any previously issued code for that email, so only the latest code works.
  5. Timing-safe comparison -- Verification uses constant-time string comparison to prevent timing attacks.
  6. Email account dependency -- Like all email-based authentication, security depends on the user's email account. For higher-security applications, consider pairing Email OTP with MFA (TOTP) as a second factor.

Troubleshooting

"Invalid or expired OTP"

The code does not match or has expired. Common causes:

  • The user mistyped the code (check for typos, especially leading zeros).
  • More than 10 minutes passed since the code was sent.
  • The user requested a new code, which invalidated the previous one.

Fix: Ask the user to request a fresh code and enter it promptly.

OTP email not received

  1. Verify you have an active email provider in Emails > Providers.
  2. Check that Magic Auth is enabled under Emails > Configuration (this controls OTP email delivery).
  3. Have the user check their spam or junk folder.
  4. Confirm the from address in your email provider is verified and not being blocked.

"Rate limit exceeded"

The user has sent too many requests in a short period. Wait one minute before retrying. If this happens frequently for legitimate users, consider adding a visible cooldown timer after sending a code.


Next Steps

  • Magic Links -- Link-based passwordless authentication as an alternative to OTP.
  • Email & Password -- Traditional authentication with email verification.
  • Passkeys -- WebAuthn-based biometric and security key authentication.
  • Multi-Factor Auth -- Add TOTP as a second factor on top of Email OTP.
  • Auth Configuration -- Manage all authentication methods from one place.