Skip to main content

Users and Auth

Just about any application on the public internet needs user authentication. Convex integrates with identity providers implementing the OpenID Connect protocol. Developers pass an ID token to the Convex client in order to reference the user in their queries and mutations.

Authentication in Convex is checked at the query or mutation function level, so new or signed-out users of an app can still subscribe to queries and even make mutations if you let them! Most apps require authentication to query or mutate data and present only a simple login page to logged-out users. That's that pattern we'll use here, requiring users log in use our chat app.

This page builds on the code in the previous section from the 2-filters-and-references directory in the Convex Demos repo. The completed demo code is in the 3-users-and-auth directory.

Identity providers

Developers can choose any OpenID-compliant authentication provider (e.g. Google, Github, Facebook) for use with their Convex app. For the purposes of this tutorial we'll be using Auth0, which provides "universal login" across a number of supported providers. Auth0 has a free tier which will be more than enough for our purposes.

Getting started with Auth0

You'll need to start by creating a free account with Auth0. Next, create a new single-page app from your Auth0 dashboard:

Create a new application

From here, we'll roughly be following Auth0's React Login guide, shown after creating your app.

In your application settings, we need to tell Auth0 what URL origins can log in and log out. For local development, you can just use http://localhost:3000 for the Callback URL, Logout URL, and Allowed Web Origin fields.

At the top of the page you'll also see your Client ID and Domain, which you'll need for the next step.

Setting up Convex with your chosen identity provider

Your Convex deployment needs to be configured with your chosen identity provider(s) to ensure that only valid identity tokens are accepted.

In your terminal, run the following command to configure a new identity provider:

npx convex auth add

You'll be prompted first for the identity provider's domain and then for your Client ID, both of which are shown in your Auth0 Application Settings.

npx convex auth add

After adding the identity provider, run npx convex push to apply your changes.

Setting up the app

Our Convex deployment is now prepared to accept ID Tokens from Auth0, but we need to make a few changes to our React app to fetch the tokens from Auth0 and pass them to Convex. We'll use Auth0's React SDK for this.

Install the Auth0 React SDK

In your project directory, run the following command:

npm install @auth0/auth0-react

Set up the authed Convex provider component

In order to ensure our application code only runs when it can access an authed ConvexReactClient, we'll use ConvexProviderWithAuth0 to show a login or loading component until the user has logged in.

tip

If you need to customize the login flow or use another auth provider, you can use the whatever client-side authentication method your auth provider suggests and call ConvexReactClient.setAuth() with the user's identity token once they've completed the login flow.

This will be easiest to do from src/main.tsx, where the main App component gets rendered. Replace the ConvexProvider component with the ConvexProviderWithAuth0 component, filling in authInfo from convex.json.

src/main.tsx
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App, { Login } from "./App";
import { ConvexProviderWithAuth0 } from "convex/react-auth0";
import { ConvexReactClient } from "convex/react";
import convexConfig from "../convex.json";

const convex = new ConvexReactClient(convexConfig.origin);
const authInfo = convexConfig.authInfo[0];

ReactDOM.render(
<StrictMode>
<ConvexProviderWithAuth0
client={convex}
authInfo={authInfo}
loggedOut={<Login />}
>
<App />
</ConvexProviderWithAuth0>
</StrictMode>,
document.getElementById("root")
);

This wrapper component renders a login view until the user has logged in, not rendering its child components until then. Once the user has logged in, all components inside the main App component can access authentication state information through the useAuth0 hook.

Add login and logout buttons

Notice the addition of loggedOut=<Login /> in the above code: ConvexProviderWithAuth0 will show a simple login button by default, but we can customize that view by passing in a JSX expression that contains our own login button as the loggedOut prop.

The values provided by useAuth0 make it easy to create login and logout buttons: the logout and loginWithRedirect values are functions that can be called from event handlers.

src/App.tsx
export function Login() {
const { isLoading, loginWithRedirect } = useAuth0();
if (isLoading) {
return <button className="btn btn-primary">Loading...</button>;
}
return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<button className="btn btn-primary" onClick={loginWithRedirect}>
Log in
</button>
</span>
</div>
</main>
);
}

Let's make a logout button too:

function Logout() {
const { logout, user } = useAuth0();
return (
<div>
{/* We know this component only renders if the user is logged in. */}
<p>Logged in{user!.name ? ` as ${user!.name}` : ""}</p>
<button
className="btn btn-primary"
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</button>
</div>
);
}

You can add this at the top of the rendered application, replacing the old randomly-generated user string. Like so:

<div className="text-center">
<span>
<Logout />
</span>
</div>

Updating our app

Next, let's start changing the core application logic to be aware of authentication state.

We'll give an overview of the steps, then present a code example that puts them together.

Store the user in the Convex deployment

ConvexProviderWithAuth0 provides our application with a Convex client that has an ID token set. We can access information about the authenticated user in our queries and mutations through the Auth.getUserIdentity method of the auth property of the QueryCtx or MutationCtx instance passed as first arguments to every Convex function invocation. The returned UserIdentity contains a tokenIdentifier string which can be used as a unique identifier for the user object in a table.

If this call doesn't return null, we know the user is authenticated. But just having access to the user's identity inside a function is only half of what we need. Most applications will want to persist the user so they can be referenced as a foreign key from other documents (in our case, from the messages table).

We'll add a storeUser mutation that runs after the user has logged in to add the user to a users table, updating if the entry already exists.

Using the getUserIdentity method and the db methods from previous examples, you should end up with a mutation function that looks like this:

convex/storeUser.ts
import { Document, Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Insert or update the user in a Convex table then return the document's Id.
//
// The `UserIdentity` returned from `auth.getUserIdentity` is just an ephemeral
// object representing the identity of the authenticated user; most applications
// will want to store this in a `users` table to reference it in their other
// tables.
//
// The `UserIdentity.tokenIdentifier` string is a stable and unique value we use
// to look up identities, but inserting the value into a table also gives us an
// `_id` field.
//
// Keep in mind that `UserIdentity` has a number of optional fields, the
// presence of which depends on the identity provider chosen. It's up to the
// application developer to determine which ones are available and to decide
// which of those need to be persisted.
export default mutation(async ({ db, auth }): Promise<Id<"users">> => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Called storeUser without authentication present");
}

// Check if we've already stored this identity before.
const user: Document<"users"> | null = await db
.table("users")
.filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.first();
if (user !== null) {
// If we've seen this identity before but the name has changed, patch the value.
if (user.name != identity.name) {
db.patch(user._id, { name: identity.name! });
}
return user._id;
}
// If it's a new identity, create a new `User`.
return db.insert("users", {
name: identity.name!,
tokenIdentifier: identity.tokenIdentifier,
// The `_id` field will be assigned by the backend.
});
});

We'll also need to update our schema in convex/schema.ts to reflect this new users table:

convex/schema.ts
users: defineTable({
name: s.string(),
tokenIdentifier: s.string(),
}),

We're switching back to the UI, so update the generated client with npx convex codegen. Now, call this mutation whenever the application mounts by placing it in a useEffect, which should look like this (at the top of your App component):

src/App.tsx
const [userId, setUserId] = useState<Id<"users"> | null>(null);
const storeUser = useMutation("storeUser");
const addChannel = useMutation("addChannel");
// Call the `storeUser` mutation function to store
// the current user in the `users` table and return the `Id` value.
useEffect(() => {
// Store the user in the database.
// Recall that `storeUser` gets the user information via the `auth`
// object on the server. You don't need to pass anything manually here.
async function createUser() {
const id = await storeUser();
setUserId(id);
}
createUser();
return () => setUserId(null);
}, [storeUser]);

Once userId is non-null we'll know that the user has been persisted to the users table in Convex.

Reference the user when sending messages

Now that the user is stored in the deployment, we can update our listMessages and sendMessage functions.

Starting with sendMessage, we'll first change the mutation to raise an error if called without authentication. We'll also want to update the UI to prevent this situation from occurring naturally, but it's important to enforce this in the mutation itself.

With authentication, sendMessage no longer needs an author parameter. Instead, we can store the user's Id and access the user's name when listing messages.

The updated sendMessage mutation should look like this:

convex/sendMessage.ts
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Send a message to the given chat channel.
export default mutation(
async ({ db, auth }, channel: Id<"channels">, body: string) => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated call to sendMessage");
}
const user = await db
.table("users")
.filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.unique();
const message = {
channel,
body,
user: user._id,
};
db.insert("messages", message);
}
);
Remember to clear your "messages" table

If you have existing messages in your database from the previous tutorial, go to the dashboard and hit the "clear table" button now.

This is necessary because the old messages won't have a user field on them.

Look up users when listing messages

The messages table no longer has a populated author field, so we'll need to look up the user reference when listing messages to find the display name.

Our query will mostly look the same, but now we'll process the messages before returning to fetch the users who sent them. The new MessageWithAuthor type represents documents from the messages table that we've augmented with an author field. The result should look like this:

convex/listMessages.ts
import { Document, Id } from "./_generated/dataModel";
import { query } from "./_generated/server";

export type MessageWithAuthor = Document<"messages"> & {
author: string;
};

// List all chat messages in the given channel.
export default query(
async ({ db }, channel: Id<"channels">): Promise<MessageWithAuthor[]> => {
const messages = await db
.table("messages")
.filter(q => q.eq(q.field("channel"), channel))
.collect();
return Promise.all(
messages.map(async message => {
// For each message in this channel, fetch the `User` who wrote it and
// insert their name into the `author` field.
const user = await db.get(message.user);
return {
author: user!.name,
...message,
};
})
);
}
);

Protect channel creation

Although channels won't have any reference to the user who created them, we still want to ensure that the addChannel mutation requires the request to be authenticated. This is the simplest way to use auth: throw an error if we don't find an identity.

convex/addChannel.ts
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Create a new chat channel.
export default mutation(
async ({ db, auth }, name: string): Promise<Id<"channels">> => {
const identity = await auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated call to addChannel");
}
return db.insert("channels", { name });
}
);

Final UI touches

We've already added a login/logout button above that shows the user's name when signed in, so all that remains is to restrict sending messages and creating channels to signed-in users in the UI.

Earlier, in the authentication useEffect hook, we also set up a user state hook representing the currently-authenticated user. We can update our submit buttons to have the disabled property when the user is null, for that brief period between the body of the chat app appearing and the storeUser mutation returning.

Application code

Putting it all together, you should end up with a authenticated chat application that looks similar to this:

src/App.tsx
import { useState, FormEvent, useEffect } from "react";
import { useAuth0 } from "@auth0/auth0-react";
import { useMutation, useQuery } from "../convex/_generated/react";
import type { MessageWithAuthor } from "../convex/listMessages";
import { Id } from "../convex/_generated/dataModel";

// Render a chat message.
function MessageView(props: { message: MessageWithAuthor }) {
const message = props.message;
return (
<div>
<strong>{message.author}:</strong> {message.body}
</div>
);
}

function ChatBox(props: { channelId: Id<"channels"> }) {
// Dynamically update `messages` in response to the output of
// `listMessages.ts`.
const messages = useQuery("listMessages", props.channelId) || [];
const sendMessage = useMutation("sendMessage");

// Run `sendMessage.ts` as a mutation to record a chat message when
// `handleSendMessage` triggered.
const [newMessageText, setNewMessageText] = useState("");
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
setNewMessageText(""); // reset text entry box
await sendMessage(props.channelId, newMessageText);
}

return (
<div className="chat-box">
<ul className="list-group shadow-sm my-3">
{messages.slice(-10).map(message => (
<li
key={message._id.toString()}
className="list-group-item d-flex justify-content-between"
>
<MessageView message={message} />
<div className="ml-auto text-secondary text-nowrap">
{new Date(message._creationTime).toLocaleTimeString()}
</div>
</li>
))}
</ul>
<form
onSubmit={handleSendMessage}
className="d-flex justify-content-center"
>
<input
value={newMessageText}
onChange={event => setNewMessageText(event.target.value)}
className="form-control w-50"
placeholder="Write a message…"
/>
<input
type="submit"
value="Send"
disabled={!newMessageText}
className="ms-2 btn btn-primary"
/>
</form>
</div>
);
}

function Logout() {
const { logout, user } = useAuth0();
return (
<div>
{/* We know this component only renders if the user is logged in. */}
<p>Logged in{user!.name ? ` as ${user!.name}` : ""}</p>
<button
className="btn btn-primary"
onClick={() => logout({ returnTo: window.location.origin })}
>
Log out
</button>
</div>
);
}

export function Login() {
const { isLoading, loginWithRedirect } = useAuth0();
if (isLoading) {
return <button className="btn btn-primary">Loading...</button>;
}
return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<button className="btn btn-primary" onClick={loginWithRedirect}>
Log in
</button>
</span>
</div>
</main>
);
}

export default function App() {
const [userId, setUserId] = useState<Id<"users"> | null>(null);
const storeUser = useMutation("storeUser");
const addChannel = useMutation("addChannel");
// Call the `storeUser` mutation function to store
// the current user in the `users` table and return the `Id` value.
useEffect(() => {
// Store the user in the database.
// Recall that `storeUser` gets the user information via the `auth`
// object on the server. You don't need to pass anything manually here.
async function createUser() {
const id = await storeUser();
setUserId(id);
}
createUser();
return () => setUserId(null);
}, [storeUser]);

// Dynamically update `channels` in response to the output of
// `listChannels.ts`.
const channels = useQuery("listChannels") || [];

// Records the Convex document ID for the currently selected channel.
const [channelId, setChannelId] = useState<Id<"channels"> | null>(null);

// Run `addChannel.ts` as a mutation to create a new channel when
// `handleAddChannel` is triggered.
const [newChannelName, setNewChannelName] = useState("");

async function handleAddChannel(event: FormEvent) {
event.preventDefault();
setNewChannelName("");
const id = await addChannel(newChannelName);
setChannelId(id);
}

return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<div className="text-center">
<span>
<Logout />
</span>
</div>
<br />
<div className="main-content">
<div className="channel-box">
<div className="list-group shadow-sm my-3">
{channels.map(channel => (
<a
key={channel._id.toString()}
className="list-group-item channel-item d-flex justify-content-between"
style={{
display: "block",
fontWeight: channel._id.equals(channelId) ? "bold" : "normal",
}}
onClick={() => setChannelId(channel._id)}
>
{channel.name}
</a>
))}
</div>
<form
onSubmit={handleAddChannel}
className="d-flex justify-content-center"
>
<input
value={newChannelName}
onChange={event => setNewChannelName(event.target.value)}
className="form-control w-50"
placeholder="Add a channel..."
disabled={userId === null}
/>
<input
type="submit"
value="Add"
className="ms-2 btn btn-primary"
disabled={!newChannelName || userId === null}
/>
</form>
</div>
{channelId ? <ChatBox channelId={channelId} /> : null}
</div>
</main>
);
}

Try it out

Push your Convex functions to the cloud with npx convex push, run the UI locally with npm run dev, then navigate your browser to localhost:3000 to try it out.