Skip to main content

Email + password

The email + password sign-in method implements password-based login where each account is identified by a validated email address. It handles:

  • Sign-up with email validation: the account is created immediately, but the user can sign in only after they open the validation link.
  • Sign-in.
  • Change password and change email, which require the current password and send a security notification to the affected address.
  • Password reset through a reset link received by email.

A validation link works only in the browser (or app) that started the flow: the client keeps a secret in its storage, and completion requires both that secret and the code from the link.

Emails are sent through the @convex-dev/resend component.

Setting up email + password login in your app​

  1. Follow the “Getting Started” guide

    First, install Convex Auth and set up the core as described in the Getting Started guide.

  2. Set up Resend

    This method uses Resend to send emails for account validation, password recovery, and security notifications.

    You will need a domain to send these emails from.

    Before going further:

    1. Create a Resend account
    2. Set up your domain in Resend
    3. Create a Resend API key

    Then connect Resend to your Convex deployment:

    npm install @convex-dev/resend
    npx convex env set RESEND_API_KEY # Press Enter then enter your Resend API key
    npx convex env set SENDER_EMAIL no-reply@my-domain.example # Replace this with an email address in your verified domain
    npx convex env set SENDER_NAME "My App" # Optional: replace this with the sender name of your emails
  3. Register the components

    Register the email component, the password component, and the Resend component alongside the core in convex/convex.config.ts.

    convex/convex.config.ts
    import { defineApp } from "convex/server";
    import { v } from "convex/values";
    import auth from "@convex-dev/auth/convex.config.js";
    import authPassword from "@convex-dev/auth/components/password/convex.config.js";
    import authEmail from "@convex-dev/auth/components/email/convex.config.js";
    import resend from "@convex-dev/resend/convex.config.js";

    const app = defineApp({
    env: {
    RESEND_API_KEY: v.string(),
    SENDER_EMAIL: v.string(),
    // Defaults in auth.ts.
    SENDER_NAME: v.optional(v.string()),
    SITE_URL: v.optional(v.string()),
    },
    });

    app.use(auth);
    app.use(authPassword);
    app.use(authEmail);
    app.use(resend);

    export default app;
  4. Add a users table to the schema

    Add a users table to your schema in convex/schema.ts. You don’t need to add any fields to it.

    convex/schema.ts
    import { defineSchema, defineTable } from "convex/server";

    export default defineSchema({
    users: defineTable({}),
    });
  5. Set up user creation

    Write a createUser mutation that creates a row in your users table and returns its id. This function will be used every time someone creates a new account.

    convex/users.ts
    import { internalMutation } from "./_generated/server";
    import { v } from "convex/values";

    export const createUser = internalMutation({
    args: {
    provider: v.object({
    name: v.literal("emailPassword"),
    accountId: v.string(),
    profile: v.object({}),
    }),
    },
    returns: v.id("users"),
    handler: async (ctx) => {
    return await ctx.db.insert("users", {});
    },
    });
  6. Set up the public backend functions

    Set up the emailPassword method with setupEmailPassword and export the mutations it hands back once you attach the callback that creates your user rows, in convex/auth.ts:

    convex/auth.ts
    import { components, internal } from "./_generated/api";
    import { env } from "./_generated/server";
    import { setupConvexAuth } from "@convex-dev/auth/server";
    import { setupEmailPassword } from "@convex-dev/auth/methods/email-password/server";

    // The frontend origin the emailed links point at. Set SITE_URL on the
    // deployment when the frontend does not run on the Vite default.
    const SITE_URL = env.SITE_URL ?? "http://localhost:5173";

    const auth = setupConvexAuth({ component: components.auth });
    export const { signOut, refreshSession, isAuthenticated } = auth;

    export const {
    signUpWithEmailPassword,
    completeSignUpWithEmailPassword,
    signInWithEmailPassword,
    changePassword,
    startChangeEmail,
    completeChangeEmail,
    startPasswordRecovery,
    checkPasswordRecovery,
    completePasswordRecovery,
    } = setupEmailPassword(auth, {
    component: components.authEmail,
    passwordComponent: components.authPassword,
    emailSender: {
    kind: "resend",
    sendEmail: components.resend.lib.sendEmail,
    apiKey: env.RESEND_API_KEY,
    // SENDER_EMAIL must be on a domain you verified with Resend, or Resend's
    // onboarding sender (onboarding@resend.dev), which only delivers to the
    // email address of your own Resend account.
    from: `${env.SENDER_NAME ?? "My App"} <${env.SENDER_EMAIL}>`,
    },
    urls: {
    signUp: `${SITE_URL}/validate-email`,
    changeEmail: `${SITE_URL}/confirm-email-change`,
    recovery: `${SITE_URL}/reset-password`,
    },
    }).attachUserCallbacks({
    createUser: internal.users.createUser,
    });
  7. Set up the provider on the client

    Wrap your app in ConvexAuthProvider, passing it the refreshSession and signOut mutations and the isAuthenticated query in src/main.tsx:

    src/main.tsx
    import { ConvexAuthProvider } from "@convex-dev/auth/react";
    import { ConvexReactClient } from "convex/react";
    import { StrictMode } from "react";
    import { createRoot } from "react-dom/client";
    import { BrowserRouter } from "react-router-dom";
    import { api } from "../convex/_generated/api";
    import { App } from "./App";

    const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL!);

    createRoot(document.getElementById("root")!).render(
    <StrictMode>
    <ConvexAuthProvider
    client={convex}
    api={{
    refreshSession: api.auth.refreshSession,
    signOut: api.auth.signOut,
    isAuthenticated: api.auth.isAuthenticated,
    }}
    >
    <BrowserRouter>
    <App />
    </BrowserRouter>
    </ConvexAuthProvider>
    </StrictMode>,
    );
  8. Set up the sign-up page

    In your front-end, set up a route for each flow, starting with creating an account. Copy the following components into your app. Feel free to customize them, for instance to use your app’s design system.

    src/routes/signUp.tsx
    import { useSignUpWithEmailPassword } from "@convex-dev/auth/methods/email-password/react";
    import { useState } from "react";
    import { api } from "../../convex/_generated/api";

    export function SignUp() {
    const { signUp, pending } = useSignUpWithEmailPassword(api.auth);
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState<string | null>(null);
    const [sentTo, setSentTo] = useState<string | null>(null);

    if (sentTo !== null) {
    return (
    <>
    <h1>Validate your email</h1>
    <p>
    We sent a link to <strong>{sentTo}</strong>. Open it to validate your
    address and sign in.
    </p>
    <p>
    Open the link in <strong>this browser</strong>: the link only works in
    the browser you signed up from.
    </p>
    </>
    );
    }

    return (
    <>
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setError(null);
    const result = await signUp({ email, password });
    if (result.success) {
    setSentTo(email);
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "INVALID_EMAIL":
    return "That email address doesn't look valid.";
    case "EMAIL_TAKEN":
    return "An account already exists with that email address.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(result.userError.retryAfterMs / 1000)} seconds.`;
    case "PASSWORD_TOO_SHORT":
    return `Password must be at least ${result.userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Password must be at most ${result.userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Password can't start or end with whitespace.";
    case "PASSWORD_TOO_COMMON":
    return "This password is one of the most commonly used passwords. Please choose a different one.";
    case "OTHER_ERROR":
    // The mutation threw unexpectedly; the original error is
    // available on `cause` if you want to log or inspect it.
    console.error("Sign-up failed:", result.userError.cause);
    return "Something went wrong. Please try again.";
    default:
    result.userError satisfies never;
    console.error("Sign-up failed:", result.userError);
    return "Something went wrong. Please try again.";
    }
    });
    }}
    >
    <h1>Sign up</h1>
    <label>
    Email
    <input
    type="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    // "username", not "email" (best practice for password manager support)
    autoComplete="username"
    required
    disabled={pending}
    />
    </label>
    <label>
    Password
    <input
    type="password"
    value={password}
    onChange={(e) => setPassword(e.target.value)}
    autoComplete="new-password"
    required
    disabled={pending}
    />
    </label>
    {error ? (
    <p role="alert">
    <strong>{error}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={pending}>
    {pending ? "Creating account…" : "Create account"}
    </button>
    </form>
    <p>
    Already have an account? <a href="/login">Log in</a>
    </p>
    </>
    );
    }
  9. Set up the email validation page

    Set up the landing page for the link in the sign-up email. It must not require authentication.

    src/routes/validateEmail.tsx
    import { useCompleteSignUpWithEmailPassword } from "@convex-dev/auth/methods/email-password/react";
    import type { ReactNode } from "react";
    import { useSearchParams } from "react-router-dom";
    import { api } from "../../convex/_generated/api";

    /**
    * Landing page for the sign-up challenge link (`/validate-email?code=…`).
    * The hook completes the validation as soon as the page opens, which also
    * signs the user in; the page shows the outcome.
    */
    export function ValidateEmail() {
    const [params] = useSearchParams();
    const emailCode = params.get("code");
    if (emailCode === null) {
    return <p>This link is incomplete. Use the link from your email.</p>;
    }
    return <ValidateEmailWithCode emailCode={emailCode} />;
    }

    function ValidateEmailWithCode({ emailCode }: { emailCode: string }) {
    const state = useCompleteSignUpWithEmailPassword(api.auth, { emailCode });

    if (state.status === "loading") {
    return <p>Validating your email…</p>;
    }
    if (state.status === "complete") {
    return (
    <>
    <h1>Email validated</h1>
    <p>
    Your email address is validated and you are signed in.{" "}
    <a href="/">Go to the dashboard</a>.
    </p>
    </>
    );
    }
    switch (state.userError.error) {
    case "MISSING_SECRET":
    return (
    <>
    <h1>Open this link in the browser you signed up from</h1>
    <p>
    For your security, the validation link only works in the browser
    where the sign-up started. Open the link there, or sign up again in
    this browser.
    </p>
    </>
    );
    case "INVALID_CHALLENGE":
    return (
    <ValidateEmailError>
    This link is no longer valid. It may have expired or already been
    used.
    </ValidateEmailError>
    );
    case "INCORRECT_CODE":
    return (
    <ValidateEmailError>
    This is not the latest link. Open the newest email we sent you.
    </ValidateEmailError>
    );
    case "EMAIL_TAKEN":
    return (
    <ValidateEmailError>
    This email address is already in use. Sign in instead.
    </ValidateEmailError>
    );
    case "OTHER_ERROR":
    console.error("Validation failed:", state.userError.cause);
    return (
    <ValidateEmailError>
    Something went wrong. Reload the page to try again.
    </ValidateEmailError>
    );
    default:
    state.userError satisfies never;
    console.error("Validation failed:", state.userError);
    return (
    <ValidateEmailError>
    Something went wrong. Reload the page to try again.
    </ValidateEmailError>
    );
    }
    }

    function ValidateEmailError({ children }: { children: ReactNode }) {
    return (
    <>
    <h1>Could not validate your email</h1>
    <p role="alert">
    <strong>{children}</strong>
    </p>
    <p>
    <a href="/signup">Sign up again</a> to receive a new link.
    </p>
    </>
    );
    }
  10. Set up the log-in page

    Set up a route for logging in:

    src/routes/logIn.tsx
    import { useSignInWithEmailPassword } from "@convex-dev/auth/methods/email-password/react";
    import { useState } from "react";
    import { api } from "../../convex/_generated/api";

    export function LogIn() {
    const { signIn, pending } = useSignInWithEmailPassword(api.auth);
    const [email, setEmail] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState<string | null>(null);

    return (
    <>
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setError(null);
    const result = await signIn({ email, password });
    if (result.status === "complete") {
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "USER_NOT_FOUND":
    return "No account exists with that email address. Make sure you validated your email.";
    case "INVALID_CREDENTIALS":
    return "Incorrect email or password.";
    case "PASSWORD_TOO_SHORT":
    return `Password must be at least ${result.userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Password must be at most ${result.userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Password can't start or end with whitespace.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(result.userError.retryAfterMs / 1000)} seconds.`;
    case "OTHER_ERROR":
    console.error("Sign-in failed:", result.userError.cause);
    return "Something went wrong. Please try again.";
    default:
    result.userError satisfies never;
    console.error("Sign-in failed:", result.userError);
    return "Something went wrong. Please try again.";
    }
    });
    }}
    >
    <h1>Log in</h1>
    <label>
    Email
    <input
    type="email"
    value={email}
    onChange={(e) => setEmail(e.target.value)}
    // "username", not "email" (best practice for password manager support)
    autoComplete="username"
    required
    disabled={pending}
    />
    </label>
    <label>
    Password
    <input
    type="password"
    value={password}
    onChange={(e) => setPassword(e.target.value)}
    autoComplete="current-password"
    required
    disabled={pending}
    />
    </label>
    {error ? (
    <p role="alert">
    <strong>{error}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={pending}>
    {pending ? "Logging in…" : "Log in"}
    </button>
    </form>
    <p>
    Don't have an account? <a href="/signup">Sign up</a>
    </p>
    <p>
    Forgot your password? <a href="/forgot-password">Reset it</a>
    </p>
    </>
    );
    }
  11. Set up the change-password form

    Let signed-in users change their password:

    src/ChangePasswordForm.tsx
    import { useMutation } from "convex/react";
    import { useState } from "react";
    import { api } from "../convex/_generated/api";

    export function ChangePasswordForm({
    currentEmail,
    }: {
    currentEmail: string | undefined;
    }) {
    const changePassword = useMutation(api.auth.changePassword);
    const [pending, setPending] = useState(false);
    const [currentPasswordField, setCurrentPasswordField] = useState("");
    const [newPasswordField, setNewPasswordField] = useState("");
    const [signOutOtherSessions, setSignOutOtherSessions] = useState(false);
    const [message, setMessage] = useState<string | null>(null);

    return (
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setMessage(null);
    setPending(true);
    try {
    const result = await changePassword({
    currentPassword: currentPasswordField,
    newPassword: newPasswordField,
    signOutOtherSessions,
    });
    if (result.success) {
    setCurrentPasswordField("");
    setNewPasswordField("");
    setMessage("Your password was changed.");
    return;
    }
    setMessage(() => {
    switch (result.userError.error) {
    case "NOT_LOGGED_IN":
    return "You are not logged in.";
    case "INVALID_CREDENTIALS":
    return "The current password is incorrect.";
    case "PASSWORD_TOO_SHORT":
    return `Password must be at least ${result.userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Password must be at most ${result.userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Password can't start or end with whitespace.";
    case "PASSWORD_TOO_COMMON":
    return "This password is one of the most commonly used passwords. Please choose a different one.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(result.userError.retryAfterMs / 1000)} seconds.`;
    default:
    result.userError satisfies never;
    console.error("Password change failed:", result.userError);
    return "Something went wrong. Please try again.";
    }
    });
    } catch (error) {
    console.error("Change password failed:", error);
    setMessage("Something went wrong. Please try again.");
    } finally {
    setPending(false);
    }
    }}
    >
    <h2>Change password</h2>
    {/* Hidden email, so that password managers know which login to update */}
    <input
    type="email"
    value={currentEmail ?? ""}
    // "username", not "email" (best practice for password manager support)
    autoComplete="username"
    readOnly
    hidden
    />
    <label>
    Current password
    <input
    type="password"
    value={currentPasswordField}
    onChange={(e) => setCurrentPasswordField(e.target.value)}
    autoComplete="current-password"
    required
    disabled={pending}
    />
    </label>
    <label>
    New password
    <input
    type="password"
    value={newPasswordField}
    onChange={(e) => setNewPasswordField(e.target.value)}
    autoComplete="new-password"
    required
    disabled={pending}
    />
    </label>
    <label>
    <input
    type="checkbox"
    checked={signOutOtherSessions}
    onChange={(e) => setSignOutOtherSessions(e.target.checked)}
    disabled={pending}
    />
    Sign out of all other devices
    </label>
    {message ? (
    <p role="alert">
    <strong>{message}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={pending}>
    {pending ? "Changing…" : "Change password"}
    </button>
    </form>
    );
    }
  12. Set up the change-email form

    Let signed-in users change their email address:

    src/ChangeEmailForm.tsx
    import { useStartChangeEmail } from "@convex-dev/auth/methods/email-password/react";
    import { useState } from "react";
    import { api } from "../convex/_generated/api";

    export function ChangeEmailForm({
    currentEmail,
    }: {
    currentEmail: string | undefined;
    }) {
    const { startChangeEmail, pending } = useStartChangeEmail(api.auth);
    const [newEmailField, setNewEmailField] = useState("");
    const [currentPasswordField, setCurrentPasswordField] = useState("");
    const [message, setMessage] = useState<string | null>(null);

    return (
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setMessage(null);
    const result = await startChangeEmail({
    newEmail: newEmailField,
    currentPassword: currentPasswordField,
    });
    if (result.success) {
    setMessage(
    `We sent a link to ${newEmailField}. Open it in this browser to ` +
    "confirm the change.",
    );
    return;
    }
    setMessage(() => {
    switch (result.userError.error) {
    case "NOT_LOGGED_IN":
    return "You are not logged in.";
    case "INVALID_CREDENTIALS":
    return "The current password is incorrect.";
    case "INVALID_EMAIL":
    return "That email address doesn't look valid.";
    case "EMAIL_TAKEN":
    return "An account already exists with that email address.";
    case "PASSWORD_TOO_SHORT":
    return `Password must be at least ${result.userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Password must be at most ${result.userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Password can't start or end with whitespace.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(result.userError.retryAfterMs / 1000)} seconds.`;
    case "OTHER_ERROR":
    console.error("Change email failed:", result.userError.cause);
    return "Something went wrong. Please try again.";
    default:
    result.userError satisfies never;
    console.error("Email change failed:", result.userError);
    return "Something went wrong. Please try again.";
    }
    });
    }}
    >
    <h2>Change email</h2>
    {/* Hidden email, so that password managers know which account to update */}
    <input
    type="hidden"
    name="username"
    value={currentEmail ?? ""}
    autoComplete="username"
    />
    <label htmlFor="new-email">New email</label>
    <input
    id="new-email"
    name="new-email"
    type="email"
    value={newEmailField}
    onChange={(e) => setNewEmailField(e.target.value)}
    autoComplete="off"
    required
    disabled={pending}
    />
    <label htmlFor="current-password">Current password</label>
    <input
    id="current-password"
    name="current-password"
    type="password"
    value={currentPasswordField}
    onChange={(e) => setCurrentPasswordField(e.target.value)}
    autoComplete="current-password"
    required
    disabled={pending}
    />
    {message ? (
    <p role="alert">
    <strong>{message}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={pending}>
    {pending ? "Sending…" : "Send confirmation link"}
    </button>
    </form>
    );
    }
  13. Set up the email change confirmation page

    Set up the landing page for the link in the change-email email. It must not require authentication.

    src/routes/confirmEmailChange.tsx
    import { useCompleteChangeEmail } from "@convex-dev/auth/methods/email-password/react";
    import { useSearchParams } from "react-router-dom";
    import { api } from "../../convex/_generated/api";

    /**
    * Landing page for the change-email confirmation link
    * (`/confirm-email-change?code=…`). The hook completes the change as soon as
    * the page opens; the page shows the outcome. No session is minted: the user
    * already has one.
    */
    export function ConfirmEmailChange() {
    const [params] = useSearchParams();
    const emailCode = params.get("code");
    if (emailCode === null) {
    return <p>This link is incomplete. Use the link from your email.</p>;
    }
    return <ConfirmEmailChangeWithCode emailCode={emailCode} />;
    }

    function ConfirmEmailChangeWithCode({ emailCode }: { emailCode: string }) {
    const state = useCompleteChangeEmail(api.auth, {
    emailCode,
    });

    if (state.status === "loading") {
    return <p>Confirming your new email…</p>;
    }
    if (state.status === "complete") {
    return (
    <>
    <h1>Email address changed</h1>
    <p>
    The new address is now the primary address of your account.{" "}
    <a href="/">Back to the dashboard</a>.
    </p>
    </>
    );
    }
    switch (state.userError.error) {
    case "MISSING_SECRET":
    return (
    <>
    <h1>Open this link in the browser you started from</h1>
    <p>
    For your security, the confirmation link only works in the browser
    where the email change started.
    </p>
    </>
    );
    case "INVALID_CHALLENGE":
    return (
    <ConfirmEmailChangeError message="This link is no longer valid. It may have expired or already been used." />
    );
    case "INCORRECT_CODE":
    return (
    <ConfirmEmailChangeError message="This is not the latest link. Open the newest email we sent you." />
    );
    case "NOT_LOGGED_IN":
    return (
    <ConfirmEmailChangeError message="Log in first, then open the link again." />
    );
    case "EMAIL_TAKEN":
    return (
    <ConfirmEmailChangeError message="Another account validated this email address first." />
    );
    case "WRONG_USER":
    return (
    <ConfirmEmailChangeError message="This link is for another account. Log in with the account that started the change, then open the link again." />
    );
    case "OTHER_ERROR":
    console.error("Email change failed:", state.userError.cause);
    return (
    <ConfirmEmailChangeError message="Something went wrong. Reload the page to try again." />
    );
    default:
    state.userError satisfies never;
    console.error("Email change failed:", state.userError);
    return (
    <ConfirmEmailChangeError message="Something went wrong. Reload the page to try again." />
    );
    }
    }

    function ConfirmEmailChangeError({ message }: { message: string }) {
    return (
    <>
    <h1>Could not change your email</h1>
    <p role="alert">
    <strong>{message}</strong>
    </p>
    <p>
    Start the email change again from the <a href="/">dashboard</a>.
    </p>
    </>
    );
    }
  14. Set up the password reset request page

    Let users who forgot their password request a reset link:

    src/routes/requestReset.tsx
    import { useStartPasswordRecovery } from "@convex-dev/auth/methods/email-password/react";
    import { useState } from "react";
    import { api } from "../../convex/_generated/api";

    /** The `/forgot-password` page: send a password-reset link. */
    export function RequestReset() {
    const { startPasswordRecovery, pending } = useStartPasswordRecovery(api.auth);
    const [emailField, setEmailField] = useState("");
    const [error, setError] = useState<string | null>(null);
    const [sentTo, setSentTo] = useState<string | null>(null);

    if (sentTo !== null) {
    return (
    <>
    <h1>Check your email</h1>
    <p>
    We sent a password-reset link to <strong>{sentTo}</strong>. The link
    stops working after 10 minutes.
    </p>
    <p>
    Open the link in <strong>this browser</strong>: the link only works in
    the browser you requested it from.
    </p>
    </>
    );
    }

    return (
    <>
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setError(null);
    const result = await startPasswordRecovery({ email: emailField });
    if (result.success) {
    setSentTo(result.sentTo);
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "INVALID_EMAIL":
    return "That email address doesn't look valid.";
    case "EMAIL_NOT_FOUND":
    return "No account exists with that email address.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(result.userError.retryAfterMs / 1000)} seconds.`;
    case "OTHER_ERROR":
    console.error("Reset request failed:", result.userError.cause);
    return "Something went wrong. Please try again.";
    default:
    result.userError satisfies never;
    console.error(
    "Password reset request failed:",
    result.userError,
    );
    return "Something went wrong. Please try again.";
    }
    });
    }}
    >
    <h1>Reset your password</h1>
    <label>
    Email
    <input
    type="email"
    value={emailField}
    onChange={(e) => setEmailField(e.target.value)}
    autoComplete="username"
    required
    disabled={pending}
    />
    </label>
    {error ? (
    <p role="alert">
    <strong>{error}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={pending}>
    {pending ? "Sending…" : "Send reset link"}
    </button>
    </form>
    <p>
    Remembered your password? <a href="/login">Log in</a>
    </p>
    </>
    );
    }
  15. Set up the password reset page

    Set up the landing page for the link in the password reset email. It must not require authentication.

    src/routes/resetPassword.tsx
    import { useCompletePasswordRecovery } from "@convex-dev/auth/methods/email-password/react";
    import { useState, type ReactNode } from "react";
    import { Navigate, useSearchParams } from "react-router-dom";
    import { api } from "../../convex/_generated/api";

    /**
    * Landing page for the password-reset link (`/reset-password?code=…`).
    * Checks the link when the page opens, then asks for the new password and
    * signs the user in on success. A link that cannot be used shows an error
    * instead of the form.
    */
    export function ResetPassword() {
    const [params] = useSearchParams();
    const emailCode = params.get("code");
    if (emailCode === null) {
    return <p>This link is incomplete. Use the link from your email.</p>;
    }
    return <ResetPasswordWithCode emailCode={emailCode} />;
    }

    function ResetPasswordWithCode({ emailCode }: { emailCode: string }) {
    const state = useCompletePasswordRecovery(api.auth, { emailCode });
    const [newPasswordField, setNewPasswordField] = useState("");
    const [error, setError] = useState<string | null>(null);

    switch (state.status) {
    case "loading":
    return <p>Checking the link…</p>;
    case "complete":
    return <Navigate to="/" replace />;
    case "error":
    switch (state.userError.error) {
    case "MISSING_SECRET":
    return (
    <>
    <h1>Open this link in the browser you started from</h1>
    <p>
    For your security, the reset link only works in the browser
    where the reset was requested. Open the link there, or{" "}
    <a href="/forgot-password">request a new link</a> in this
    browser.
    </p>
    </>
    );
    case "INVALID_CHALLENGE":
    return (
    <ResetPasswordError>
    This link is no longer valid. It may have expired (reset links
    stop working after 10 minutes) or already been used.
    </ResetPasswordError>
    );
    case "INCORRECT_CODE":
    return (
    <ResetPasswordError>
    This is not the latest link. Open the newest email we sent you.
    </ResetPasswordError>
    );
    default:
    state.userError satisfies never;
    console.error("Password reset failed:", state.userError);
    return (
    <ResetPasswordError>
    Something went wrong. Reload the page to try again.
    </ResetPasswordError>
    );
    }
    case "ready":
    break;
    }

    return (
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setError(null);
    const result = await state.completePasswordRecovery({
    newPassword: newPasswordField,
    });
    if (result.status === "complete") {
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "INVALID_CHALLENGE":
    case "INCORRECT_CODE":
    // The hook moves to its error state; the page re-renders.
    return null;
    case "PASSWORD_TOO_SHORT":
    return `Password must be at least ${result.userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Password must be at most ${result.userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Password can't start or end with whitespace.";
    case "PASSWORD_TOO_COMMON":
    return "This password is one of the most commonly used passwords. Please choose a different one.";
    case "OTHER_ERROR":
    console.error("Password reset failed:", result.userError.cause);
    return "Something went wrong. Please try again.";
    default:
    result.userError satisfies never;
    console.error("Password reset failed:", result.userError);
    return "Something went wrong. Please try again.";
    }
    });
    }}
    >
    <h1>Choose a new password</h1>
    <label>
    New password
    <input
    type="password"
    value={newPasswordField}
    onChange={(e) => setNewPasswordField(e.target.value)}
    autoComplete="new-password"
    required
    disabled={state.pending}
    />
    </label>
    {error ? (
    <p role="alert">
    <strong>{error}</strong>
    </p>
    ) : null}
    <button type="submit" disabled={state.pending}>
    {state.pending ? "Resetting…" : "Reset password and sign in"}
    </button>
    </form>
    );
    }

    function ResetPasswordError({ children }: { children: ReactNode }) {
    return (
    <>
    <h1>Could not reset your password</h1>
    <p role="alert">
    <strong>{children}</strong>
    </p>
    <p>
    <a href="/forgot-password">Request a new link</a>.
    </p>
    </>
    );
    }

Rate limits​

This method rate-limits the flows that send emails or tell whether an address has an account. When a limit is reached, the call returns a RATE_LIMITED error with retryAfterMs, the number of milliseconds until the next attempt can succeed. Show it to the user, as logIn.tsx does above.

Each limit is a token bucket: the user can make as many attempts as the capacity at once, then the bucket refills at a steady rate.

LimitApplies toCapacityRefill
Emails per destination addressSign-up validation, change-email confirmation, and password reset links sent to one address51 every 12 min
Emails per client IPThe same emails, sent from one client IP to any address201 every 3 min
Account lookups per client IPSign-in, starting a password reset, and starting a sign-up or an email change (which tells whether the address is taken)601 every minute
Password attempts per userSign-in, change password, and change email (which check the current password)51 every minute

The limit per destination address is shared by all of the kinds of emails. For instance, a user who requested 5 password reset links in a row must wait about 12 minutes before they can request a link to change their email to that address. Security notifications, such as the one sent after a password change, don't count toward these limits.

These values aren't configurable yet.