Skip to main content

Username + password

The password component lets you implement password-based login. It automatically handles subtle security details of password handling, including hashing (with Argon2id), rate limiting, and validation.

Setting up username + 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. Install the components

    Register the password component and the username component alongside the core in convex/convex.config.ts. The username component stores which user each username identifies:

    convex/convex.config.ts
    import { defineApp } from "convex/server";
    import auth from "@convex-dev/auth/convex.config.js";
    import authPassword from "@convex-dev/auth/components/password/convex.config.js";
    import authUsername from "@convex-dev/auth/components/username/convex.config.js";

    const app = defineApp();

    app.use(auth);
    app.use(authPassword);
    app.use(authUsername);

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

    Add an empty users table to your schema in convex/schema.ts. The username component keeps the username, so the table does not need a field for it.

    Convex Auth will create a row for each account. You can also add application-specific fields to this table if necessary.

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

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

    Write a createUser mutation that creates a row in your users table and returns its id. The method calls it the first time it sees an account.

    You can also attach an optional onSignIn mutation, called with the resolved userId on every sign-in, the first one included. Anything you want to happen each time someone signs in belongs there, so createUser only has to handle what is true at creation time. Leave it out if there is nothing to do.

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

    /**
    * Create the user row for a new password account and return its id. This
    * example keeps no data in the row, but your app can put a profile here.
    */
    export const createUser = internalMutation({
    args: {
    provider: v.object({
    name: v.literal("password"),
    accountId: v.string(),
    profile: v.object({}),
    }),
    },
    returns: v.id("users"),
    handler: async (ctx) => {
    return await ctx.db.insert("users", {});
    },
    });
  5. Set up the public backend functions

    Set up the usernamePassword method with setupUsernamePassword and export the sign-up and sign-in 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 { setupConvexAuth } from "@convex-dev/auth/server";
    import { setupUsernamePassword } from "@convex-dev/auth/methods/username-password/server";

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

    export const {
    signUpWithUsernamePassword,
    signInWithUsernamePassword,
    changePassword,
    } = setupUsernamePassword(auth, {
    component: components.authPassword,
    usernameComponent: components.authUsername,
    }).attachUserCallbacks({ createUser: internal.users.createUser });
  6. 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>,
    );
  7. Set up the sign-up page

    In your front-end, set up a route for creating an account. Copy the following component into your app:

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

    export function SignUp() {
    const { signUp, pending } = useSignUpWithUsernamePassword(api.auth);
    const [username, setUsername] = useState("");
    const [password, setPassword] = useState("");
    const [error, setError] = useState<string | null>(null);

    return (
    <>
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setError(null);
    const result = await signUp({ username, password });
    if (result.status === "complete") {
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "USERNAME_TAKEN":
    return "That username is already taken.";
    case "USERNAME_TOO_SHORT":
    return `Username must be at least ${result.userError.minimumLength} characters.`;
    case "USERNAME_TOO_LONG":
    return `Username must be at most ${result.userError.maximumLength} characters.`;
    case "USERNAME_HAS_SURROUNDING_WHITESPACE":
    return "Username can't start or end with whitespace.";
    case "USERNAME_HAS_INVALID_CHARACTERS":
    return "Username contains characters that aren't allowed.";
    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 htmlFor="username">
    Username
    <input
    // Using a static id to help password managers behave correctly
    id="username"
    name="username"
    type="text"
    value={username}
    onChange={(e) => setUsername(e.target.value)}
    autoComplete="username"
    required
    disabled={pending}
    />
    </label>
    <label htmlFor="password">
    Password
    <input
    // Using a static id to help password managers behave correctly
    id="password"
    name="password"
    type="password"
    value={password}
    onChange={(e) => setPassword(e.target.value)}
    autoComplete="new-password"
    minLength={DEFAULT_MIN_PASSWORD_LENGTH}
    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>
    </>
    );
    }
  8. Set up the log-in page

    Then set up a route for logging in:

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

    export function LogIn() {
    const { signIn, pending } = useSignInWithUsernamePassword(api.auth);
    const [username, setUsername] = 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({ username, password });
    if (result.status === "complete") {
    return;
    }
    setError(() => {
    switch (result.userError.error) {
    case "USER_NOT_FOUND":
    return "No account exists with that username.";
    case "INVALID_CREDENTIALS":
    return "Incorrect username 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":
    // The mutation threw unexpectedly; the original error is
    // available on `cause` if you want to log or inspect it.
    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 htmlFor="username">
    Username
    <input
    // Using a static id to help password managers behave correctly
    id="username"
    name="username"
    type="text"
    value={username}
    onChange={(e) => setUsername(e.target.value)}
    autoComplete="username"
    required
    disabled={pending}
    />
    </label>
    <label htmlFor="password">
    Password
    <input
    // Using a static id to help password managers behave correctly
    id="password"
    name="password"
    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>
    </>
    );
    }

Going further​

Allowing users to change their password​

It is highly recommended that you implement a feature allowing users in your app to change their password, because a user might need it if they accidentally leak their password.

  1. Export the changePassword function

    In convex/auth.ts, export the changePassword mutation next to signUpWithUsernamePassword and signInWithUsernamePassword:

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

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

    export const {
    signUpWithUsernamePassword,
    signInWithUsernamePassword,
    changePassword,
    } = setupUsernamePassword(auth, {
    component: components.authPassword,
    usernameComponent: components.authUsername,
    }).attachUserCallbacks({ createUser: internal.users.createUser });
  2. Expose the username of the signed-in user

    The change-password form includes a hidden username field, so that password managers know which login to update. Write a query that returns the username of the signed-in user:

    convex/currentUser.ts
    import { getAuthUserId } from "@convex-dev/auth/server";
    import { query } from "./_generated/server";
    import { components } from "./_generated/api";

    export const loggedInUser = query({
    args: {},
    handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (userId === null) {
    return null;
    }
    const user = await ctx.db.get("users", userId);
    if (user === null) {
    return null;
    }
    const username = await ctx.runQuery(
    components.authUsername.public.getUsername,
    { userId },
    );
    if (username === null) {
    // In this app, every user has a username, so this must not occur.
    throw new Error(`User ${userId} unexpectedly has no username`);
    }

    return { id: user._id, username };
    },
    });
  3. Build the change-password form

    Copy the following component into your app, adapt it to your design system, and render it on your settings page.

    The form asks users to re-authenticate by entering their current password for security reasons.

    src/PasswordSettings.tsx
    import type { ChangePasswordResult } from "@convex-dev/auth/methods/username-password/server";
    import { DEFAULT_MIN_PASSWORD_LENGTH } from "@convex-dev/auth/methods/username-password";
    import { useMutation, useQuery } from "convex/react";
    import { useState } from "react";
    import { api } from "../convex/_generated/api";

    export function PasswordSettings() {
    const user = useQuery(api.currentUser.loggedInUser);
    const changePassword = useMutation(api.auth.changePassword);
    const [currentPassword, setCurrentPassword] = useState("");
    const [newPassword, setNewPassword] = useState("");
    const [signOutOtherSessions, setSignOutOtherSessions] = useState(false);
    const [pending, setPending] = useState(false);
    const [status, setStatus] = useState<string | null>(null);

    if (!user) {
    // Loading, or not signed in
    return null;
    }

    return (
    <form
    onSubmit={async (e) => {
    e.preventDefault();
    setStatus(null);
    setPending(true);
    try {
    const result = await changePassword({
    currentPassword,
    newPassword,
    signOutOtherSessions,
    });
    if (result.success) {
    setCurrentPassword("");
    setNewPassword("");
    setStatus("Your password has been changed.");
    } else {
    setStatus(errorMessage(result.userError));
    }
    } catch (cause) {
    console.error("Password change failed:", cause);
    setStatus("Something went wrong. Please try again.");
    } finally {
    setPending(false);
    }
    }}
    >
    <h2>Change your password</h2>
    {/* Hidden username, so that password managers know which login to update */}
    <input
    // Using a static id to help password managers behave correctly
    id="username"
    name="username"
    type="text"
    value={user.username}
    autoComplete="username"
    readOnly
    style={{ display: "none" }}
    />
    <label htmlFor="current-password">
    Current password
    <input
    // Using a static id to help password managers behave correctly
    id="current-password"
    name="current-password"
    type="password"
    value={currentPassword}
    onChange={(e) => setCurrentPassword(e.target.value)}
    autoComplete="current-password"
    required
    disabled={pending}
    />
    </label>
    <label htmlFor="new-password">
    New password
    <input
    // Using a static id to help password managers behave correctly
    id="new-password"
    name="new-password"
    type="password"
    value={newPassword}
    onChange={(e) => setNewPassword(e.target.value)}
    autoComplete="new-password"
    minLength={DEFAULT_MIN_PASSWORD_LENGTH}
    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>
    {status && (
    <p role="alert">
    <strong>{status}</strong>
    </p>
    )}
    <button type="submit" disabled={pending}>
    {pending ? "Changing password…" : "Change password"}
    </button>
    </form>
    );
    }

    function errorMessage(
    userError: Extract<ChangePasswordResult, { success: false }>["userError"],
    ): string {
    switch (userError.error) {
    case "INVALID_CREDENTIALS":
    return "Incorrect current password.";
    case "RATE_LIMITED":
    return `Too many attempts. Try again in ${Math.ceil(userError.retryAfterMs / 1000)} seconds.`;
    case "PASSWORD_TOO_SHORT":
    return `Your new password must be at least ${userError.minimumLength} characters.`;
    case "PASSWORD_TOO_LONG":
    return `Your new password must be at most ${userError.maximumLength} characters.`;
    case "PASSWORD_HAS_SURROUNDING_WHITESPACE":
    return "Your new password can't start or end with whitespace.";
    case "PASSWORD_TOO_COMMON":
    return "Your new password is one of the most commonly used passwords. Please choose a different one.";
    case "NOT_SIGNED_IN":
    return "Your session has ended. Please log in again.";
    default:
    userError satisfies never;
    return `Unknown error: ${JSON.stringify(userError)}`;
    }
    }
Recommendation: implement the well-known change-password URL

Password managers open /.well-known/change-password on your website when a user wants to change their password. To provide a good user experience, we recommend redirecting that path to your settings page.