Bright ideas and techniques for building with Convex.
Profile image
Michal Srb
9 months ago

Custom Authentication (with Lucia)

Screenshot of an authentication UI

Implementing user authentication (sign up, log in, log out) is hard! Authentication is an end-to-end feature involving secrets and credentials - the most sensitive kind of app development. For this reason it’s a good idea to outsource this problem to auth solution providers like Clerk or Auth0, or OAuth providers like Google or Github.

Unfortunately using a third-party comes with its own challenges: User data needs to be synced between the external service and your database. Auth solution providers need to be configured, can come with technical limitations and might charge a hefty sum once your app surpasses their free tiers.

Similarly, OAuth providers can be difficult to set up initially, and they can limit audience if your users do not have an account with a given provider.

For all these reasons you might want to implement authentication yourself. To simplify this, it’s a good idea to use an authentication library such as Lucia, which will manage the logic around users, sessions and credentials for you.

With Lucia and Convex, we can build APIs reading current user information simply like this:

import { queryWithAuth } from "./withAuth";

export const getLoggedInUserEmail = queryWithAuth({
  args: {},
  handler: async (ctx) => {
	  return ctx.session?.user.email;
  }
});

No external database or provider required!

We put together a template which includes sign up, log in and log out flows. Check out the GitHub repo or follow along with this article for the details.

Update: Even easier integration

Run npm create convex@latest -- -t nextjs-lucia-shadcn to start a new project with a template that includes authentication based on Lucia, using the convex-lucia-auth NPM library.

The rest of this article goes over the manual integration of Lucia with Convex, which is still helpful for understanding and in case you want total control over configuring Lucia.

Overview of the user experience

For this template we implemented a sign up / sign in flow which asks the user for their email and password. Once the user clicks Sign Up or Sign In their credentials are checked and they are either logged in or an error message is shown.

Once the user is logged-in we show them data associated with their account, in our case that’s their email, and a button that enables them to log out.

Let’s look at how this experience is implemented.

Step 1: Specify our database schema

Although Convex doesn’t strictly require you to declare your database schema up front, in this case it will be helpful for us to look at what data we’ll be storing, and to get types since we’ll be using TypeScript for end-to-end type safety throughout our auth implementation.

// convex/schema.ts

import { defineSchema, defineTable } from "convex/server";
import { Validator, v } from "convex/values";

export default defineSchema({
  users: defineTable({
    id: v.string(),
    email: v.string(),
  }).index("byId", ["id"]),
  sessions: defineTable({
    id: v.string(),
    user_id: v.string(),
    active_expires: v.float64(),
    idle_expires: v.float64(),
  })
    .index("byId", ["id"])
    .index("byUserId", ["user_id"]),
  auth_keys: defineTable({
    id: v.string(),
    hashed_password: v.union(v.string(), v.null()),
    user_id: v.string(),
  })
    .index("byId", ["id"])
    .index("byUserId", ["user_id"]),
});

We declare three tables:

  • users which stores data for each user
  • sessions which stores data for each session, mainly to do with its expiration
  • and auth_keys, which represent different user credentials using any sign-in method

This schema will be used by our database adapter. It satisfies Lucia’s requirements. The only caveat to mention is that Lucia is currently architected to allocate IDs itself, while Convex allocates IDs automatically when documents are created. For this reason our users, sessions and auth_keys will have two ID fields: Convex allocated, built-in _id and Lucia allocated id.

For this demo we added an email field for storing the user’s email.

In our template repo we split the schema between the fields required by Lucia and custom fields, so our schema looks much simpler:

export default defineSchema({
  ...authTables({
    user: { email: v.string() },
    session: {},
  }),
});

Let’s get on with implementing the database adapter that will enable us to take advantage of Lucia’s features.

Step 2: Configure Lucia

We’ll want to interact with Lucia from our server queries and mutations. For this we’ll define a function which will instantiate Lucia, with our Convex adapter. This is the getAuth function:

// lucia.ts
import { lucia } from "lucia";
import { DatabaseWriter } from "./_generated/server";

export function getAuth(db: DatabaseWriter) {
  return lucia({
    adapter: convexAdapter(db),
    // TODO: Set the LUCIA_ENVIRONMENT variable to "PROD"
    // on your prod deployment's dashboard
    env: (process.env.LUCIA_ENVIRONMENT as "PROD" | undefined) ?? "DEV",
    getUserAttributes(user: UserSchema) {
      return {
        _id: user._id,
        _creationTime: user._creationTime,
        email: user.email,
      };
    },
  });
}

We can call this function from any query* or mutation, passing in ctx.db. convexAdapter is the actual database adapter implementation. Check out its source on Github.

With Convex, implementing the database was straightforward. You can check Lucia’s adapter API docs here.

In the getUserAttributes configuration we expose the _id, _creationTime and email documents fields to code loading users via getAuth.

*As you can see in the code, getAuth accepts DatabaseWriter. This is the database interface exposed to Convex mutations. Convex queries can only read from the database, not write to it, and so they have access to DatabaseReader instead. When we call getAuth from a query helper in Step 3, our types won’t perfectly match runtime execution. Lucia’s API is not split between read and write operations, type-wise. We could declare a read-only subset of Lucia’s Auth API and return it from an additional getReadOnlyAuth function, but this won’t be necessary as we’ll only use a single, read-only, method in queries: getSession.

Configuring types

Lucia has a cool way of injecting custom database types into its APIs. These are our definitions:

// convex/env.d.ts

declare namespace Lucia {
  type Auth = import("./lucia").Auth;
  type DatabaseUserAttributes = {
    _id: import("./_generated/dataModel").Id<"users">;
    _creationTime: number;
    email: string;
  };
  type DatabaseSessionAttributes = {
    _id: import("./_generated/dataModel").Id<"sessions">;
    _creationTime: number;
  };
}

If we add more fields to our "users" table that we want to access through getAuth, we can add them here.

Step 3: Leverage Lucia in our functions - queries

With the adapter and schema in place, it’s time to use them to implement our authentication logic.

Lucia includes middleware for storing and accessing session IDs on Requests and Responses. Since we want to use Convex’s reactive client which uses a WebSocket, we’ll instead pass the session ID through function arguments and return values.

Our simple “read” “middleware” will use the following function that returns a session if the given session ID is valid:

import { getAuth } from "./lucia";
import { QueryCtx } from "./_generated/server";

async function getValidExistingSession(
  ctx: QueryCtx,
  sessionId: string | null
) {
  if (sessionId === null) {
    return null;
  }
  // The cast is OK because we will only expose the existing session
  const auth = getAuth(ctx.db as DatabaseWriter);
  try {
    const session = (await auth.getSession(sessionId)) as Session | null;
    if (session === null || session.state === "idle") {
      return null;
    }
    return session;
  } catch (error) {
    // Invalid session ID
    return null;
  }
}

With this helper we can implement a query that relies on the user information attached to a session:

import { query } from "./_generated/server";

export const getLoggedInUserEmail = query({
  args: {
    sessionId: v.union(v.null(), v.string()),
    // other argument validators
  },
  handler: async (ctx, args) => {
    const session = await getValidExistingSession(ctx, args.sessionId);
    return session.user.email;
  },
});

Having to declare that we take a sessionId for every query that needs to use the session information would get old quite quickly. For this reason we implemented a queryWithAuth wrapper that does this for us. Check it out in the repo. With it, accessing session is as simple as with Convex’s built-in auth. Our query ctx gets new session field:

import { queryWithAuth } from "./withAuth";

export const getLoggedInUserEmail = queryWithAuth({
  args: {},
  handler: async (ctx) => {
	  return ctx.session?.user.email;
  }
});

We can access user information, but how does the information get into our DB in the first place?

Step 4: Leverage Lucia in our functions - mutations

Armed with getAuth we can now implement our sign up and sign in mutations:

Sign up

import { v } from "convex/values";
import { getAuth } from "./lucia";

export const signUp = mutation({
  args: {
    email: v.string(),
    password: v.string(),
  },
  handler: async (ctx, { email, password }) => {
    const auth = getAuth(ctx.db);
    const user = await auth.createUser({
      key: {
        password: password,
        providerId: "password",
        providerUserId: email,
      },
      attributes: { email },
    });
    const session = await auth.createSession({
      userId: user.userId,
      attributes: {},
    });
    return session.sessionId;
  }
});

Check out the Auth API reference. Here we use the createUser and createSession methods to sign the user up and log them in. The password field holds the secret provided by the user (in our case, a password), "password" is our sign up method (we might add OAuth provider as a different method later), and providerUserId in our case is the user-filled out email. Under the hood this method creates both a document in our "users" table and also a key in our "auth_keys" table.

We then create a session for the user and return its ID to the client. If an error occurs, such someone trying to sign up with an email that’s already taken, Lucia will throw and the error will propagate to the client.

Sign in

import { v } from "convex/values";
import { getAuth } from "./lucia";

export const signIn = mutation({
  args: {
    email: v.string(),
    password: v.string(),
  },
  handler: async (ctx, { email, password }) => {
    const auth = getAuth(ctx.db);
    const key = await auth.useKey("password", email, password);
    const session = await auth.createSession({
      userId: key.userId,
      attributes: {},
    });
    return session.sessionId;
  }
});

Check out the Auth API reference. Here we use useKey and createSesson to sign the user in.

useKey takes the sign up method ("password" in our case) and the unique providerUserId and secret (in our case, the user provided password).

The cool thing is, you are in control of how the sign up / sign in works. The two mutations presented here are the standard approach, but you could for example combine sign up and sign in into a single form with a single mutation, or sign existing users in if they accidentally go through the sign up flow. It’s up to you!

Other mutations

Just like with queries, we made a wrapper for mutations that makes it easy to check that a user is currently signed in. Since mutations can update the database, we will not only validate the session, but also renew it:

import { Auth } from "./lucia";

async function getValidSessionAndRenew(auth: Auth, sessionId: string | null) {
  if (sessionId === null) {
    return null;
  }
  try {
    // Lucia's `validateSession` auto-renews the session
    return await auth.validateSession(sessionId);
  } catch (error) {
    // Invalid session ID
    return null;
  }
}

Our wrapper uses this helper under the hood to expose both auth and session on ctx:


import { mutationWithAuth } from "./withAuth";

export const doSomethingAsTheUser = mutationWithAuth(
  args: {},
  handler: async (ctx) => {
	  if (ctx.session === null) {
	    throw new Error("Expected a logged-in user");
	  }
	  // do something...
		// ctx.auth is `LuciaAuth` instead of Convex's `Auth`
  },
});

You can see in the repo that we used this wrapper to simplify signIn and signUp mutations as well.

Step 5: Frontend

Keeping track of a session

We’ll be storing the secret sessionId in localStorage, so we create a simple React Context provider for updating and accessing it:

import { createContext, useCallback, useContext, useState } from "react";

const SessionContext = createContext(undefined as any);

export function useSessionId(): string | null {
  return useContext(SessionContext).sessionId;
}

export function useSetSessionId(): (sessionId: string | null) => void {
  return useContext(SessionContext).setSessionId;
}

export function SessionProvider({ children }: { children: React.ReactNode }) {
  const [sessionId, setSessionIdState] = useState(getSavedSessionId());
  const setSessionId = useCallback(
    (value: string | null) => {
      setSavedSessionId(value);
      setSessionIdState(value);
    },
    [setSessionIdState]
  );
  return (
    <SessionContext.Provider value={{ sessionId, setSessionId }}>
      {children}
    </SessionContext.Provider>
  );
}

function getSavedSessionId() {
  return localStorage.getItem("sessionId");
}

export function setSavedSessionId(sessionId: string | null) {
  if (sessionId == null) {
    localStorage.removeItem("sessionId");
  } else {
    localStorage.setItem("sessionId", sessionId);
  }
}

And wrap our app in it:

// src/main.tsx

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <SessionProvider>
      <ConvexProvider client={convex}>
        <App />
      </ConvexProvider>
    </SessionProvider>
  </React.StrictMode>
);

Now we can use and set session ID from anywhere in our app:

// src/App.tsx
import { useSessionId } from "./SessionProvider.tsx";
import { useQuery } from "convex/react";
import { LoginForm } from "./LoginForm";

function App() {
  const sessionId = useSessionId();
  const user = useQuery(api.users.get, {sessionId});

  return (
    <main>
      {user === undefined ? (
        <div>Loading...</div>
      ) : user == null ? (
        <LoginForm />
      ) : (
        <h2>Welcome {user.email}</h2>
      )}
    </main>
  );
}

Just like on the backend, we created a wrapper for useQuery that passes in the session ID automatically, so that we can we can simplify our code:

// src/App.tsx

// Our custom `useQuery` is imported here
import { useQuery } from "./usingSession";

function App() {
  // sessionId is passed in automatically
  const user = useQuery(api.users.get);
  // ...
}

Sign up / sign in form

Let’s see how we can implement the LoginForm component for sign up and sign in. This will be calling our signUp and signIn mutations.

import { useState } from "react";
import { useSetSessionId } from "./SessionProvider";
import { useMutation } from "./usingSession";
import { api } from "../convex/_generated/api";

export function LoginForm() {
  const setSessionId = useSetSessionId();
  const [flow, setFlow] = useState<"signIn" | "signUp">("signIn");
  const signIn = useMutation(api.users.signIn);
  const signUp = useMutation(api.users.signUp);

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    try {
      // Call the corresponding mutation
      const sessionId = await (flow === "signIn" ? signIn : signUp)({
        email: (data.get("email") as string | null) ?? "",
        password: (data.get("password") as string | null) ?? "",
      });
      // Set session ID in the Session React Context and localStorage
      setSessionId(sessionId);
    } catch {
      alert(
        flow === "signIn" ? "Invalid email or password" : "Email already in use"
      );
    }
  };
  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="username">Email</label>
      <input name="email" id="email" />
      <label htmlFor="password">Password</label>
      <input type="password" name="password" id="password" />
      {flow === "signIn" ? (
        <input type="submit" value="Sign in" />
      ) : (
        <input type="submit" value="Sign up" />
      )}
      <a
        style={{ cursor: "pointer" }}
        onClick={() => {
          setFlow(flow === "signIn" ? "signUp" : "signIn");
        }}
      >
        {flow === "signIn" ? (
          <>Don't have an account? Sign up</>
        ) : (
          <>Already have an account? Sign in</>
        )}
      </a>
    </form>
  );
}

Since they are so similar this single form implements both sign in and sign up. You could extend it in any way you like, for example by adding an OAuth sign up method (Google, Github). Lucia can help with this as well, check out Lucia OAuth docs.

Conclusion and next steps

Well, that was a wild ride. As you can see even the simplest authentication implementation has a quite a few moving pieces. Good news is that you can start off by cloning our example repo with all of these pieces already hooked up.

You now have authentication that talks directly with the Convex database, and without any redirects or need for configuring third party services.

To take your app to production you have several options:

  1. Implement optional email verification and password recovery flow. See Lucia’s guide on email verification. You’ll need a third-party (or your own) SMTP server for sending emails (check out Resend).
  2. Switch to Clerk or Auth0 before you let external users interact with your app, throwing away your existing test signups.
  3. Switch to Clerk or Auth0 later, importing existing signups into their database. See Clerk import users doc or Auth0 bulk user import doc.

If you’re using SSR with Next.js, Remix, or similar, you can use Lucia’s built-in middleware to use cookies instead of localStorage for storing sessions. Remember to protect your app against CSRF attacks.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started