Firebase phone auth with an OTP screen

SMS sign-in with Firebase: the user types a phone number, an invisible reCAPTCHA runs, and the animated 6-digit card confirms the code and unlocks the app.

Step-Up OTP Gate, preview
One payment$3

By buying you agree to the Terms.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

Use it in Next.js

app/login/page.tsx

"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RecaptchaVerifier, signInWithPhoneNumber, type ConfirmationResult } from "firebase/auth";
import { StepUpGate } from "@/components/StepUpGate";
import { auth } from "@/lib/firebase";

export default function PhoneLoginPage() {
  const router = useRouter();
  const box = useRef<HTMLDivElement>(null);
  const verifier = useRef<RecaptchaVerifier | null>(null);
  const [phone, setPhone] = useState("");
  const [confirmation, setConfirmation] = useState<ConfirmationResult | null>(null);
  const [error, setError] = useState("");

  async function sendCode(number: string) {
    setError("");
    // a fresh invisible reCAPTCHA for every send, so a retry or a resend never reuses a spent one
    verifier.current?.clear();
    const el = document.createElement("div");
    box.current!.replaceChildren(el);
    verifier.current = new RecaptchaVerifier(auth, el, { size: "invisible" });
    try {
      setConfirmation(await signInWithPhoneNumber(auth, number, verifier.current));
    } catch {
      setError("We couldn't send a code. Use the full number with the country code, like +40 712 345 678.");
    }
  }

  return (
    <>
      {confirmation ? (
        <StepUpGate
          title="Check your phone"
          description={`Enter the 6-digit code we texted to ${phone}.`}
          successTitle="You're signed in"
          successDescription="Opening your dashboard."
          onVerify={async (code) => {
            try {
              await confirmation.confirm(code);
              return true;
            } catch {
              return false; // the cells shake and show "That code didn't match"
            }
          }}
          onResend={() => void sendCode(phone)}
          onCancel={() => setConfirmation(null)}
          onVerified={() => router.push("/dashboard")}
        >
          {/* whatever should sit blurred behind the code card */}
          <div className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white/40">Your dashboard</div>
        </StepUpGate>
      ) : (
        <main className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white">
          <form
            onSubmit={(e) => {
              e.preventDefault();
              void sendCode(phone);
            }}
            className="grid w-80 gap-3"
          >
            <label htmlFor="phone" className="text-sm text-white/70">Phone number</label>
            <input id="phone" type="tel" autoComplete="tel" required placeholder="+40 712 345 678" value={phone}
              onChange={(e) => setPhone(e.target.value)}
              className="rounded-lg bg-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-[#C6F432]" />
            <button className="rounded-lg bg-[#C6F432] px-3 py-2 font-medium text-black">Text me a code</button>
            {error ? <p role="alert" className="text-sm text-red-400">{error}</p> : null}
          </form>
        </main>
      )}
      <div ref={box} />
    </>
  );
}

lib/firebase.ts

import { getApp, getApps, initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

// Reuse the app on hot reload instead of initializing it twice.
export const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
export const auth = getAuth(app);

Good to know

  • We type-checked this against firebase 13.1 and next 15.5. In the modular SDK, RecaptchaVerifier takes auth first, then the element.
  • Turn on Phone under Authentication > Sign-in method in the Firebase console. While you build, add a test number with a fixed code on the same screen, so you can sign in without real texts.
  • Numbers have to include the country code (+40..., +1...). Firebase rejects a local number with auth/invalid-phone-number, and the form shows its own message.
  • Each send makes a fresh invisible reCAPTCHA in a new element. A widget that already ran can't be used again, so a retry or a resend on the old one fails.
  • onVerify returns true or false. A wrong code makes the cells shake, and the user can type again straight away.
  • Copy StepUpGate.tsx, step-up-gate.css and timeline.ts from react/src/components. The gate frosts whatever you pass as children.

Install

  1. Copy from the zipPut react/src/components into src/, then npm i framer-motion firebase.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

onVerify
(code) → true when confirm() resolves
onResend
text a fresh code
onCancel
back to the phone number
onVerified
runs after the unlock animation
title / description
card text
length
number of cells (6 for Firebase)

How qodesign compares

All comparisons, with prices read on each library's own site.

Questions

Why do I get auth/invalid-app-credential or a reCAPTCHA error?

Usually the domain isn't on the Authorized domains list under Authentication > Settings, or a reCAPTCHA widget is being reused. localhost is on the list by default; add your live domain.

Can I use it to add a phone number as a second factor?

Yes. Firebase's multi-factor flow also ends with a 6-digit code. Put that check inside onVerify and the card works the same.

Does the session last after a reload?

Yes. Firebase keeps the signed-in user in the browser by default, and onAuthStateChanged tells you who it is on the next visit.

Does the code screen work in Next.js?

Yes, as a client component. Keep "use client" at the top of the file that renders it, as in the snippet above. App Router and Pages Router both work.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.