- Convex Authentication Setup
- Implement secure authentication in Convex with user management and access control.
- When to Use
- Setting up authentication for the first time
- Implementing user management (users table, identity mapping)
- Creating authentication helper functions
- Setting up OAuth providers (WorkOS, Auth0, etc.)
- Architecture Overview
- Convex authentication has two main parts:
- Client Authentication
-
- Use a provider (WorkOS, Auth0, custom JWT)
- Backend Identity
- Map auth provider identity to your users table
Schema Setup
// convex/schema.ts
import
{
defineSchema
,
defineTable
}
from
"convex/server"
;
import
{
v
}
from
"convex/values"
;
export
default
defineSchema
(
{
users
:
defineTable
(
{
// From auth provider identity
tokenIdentifier
:
v
.
string
(
)
,
// Unique per auth provider
// User profile data
name
:
v
.
string
(
)
,
email
:
v
.
string
(
)
,
pictureUrl
:
v
.
optional
(
v
.
string
(
)
)
,
// Your app-specific fields
role
:
v
.
union
(
v
.
literal
(
"user"
)
,
v
.
literal
(
"admin"
)
)
,
createdAt
:
v
.
number
(
)
,
updatedAt
:
v
.
optional
(
v
.
number
(
)
)
,
}
)
.
index
(
"by_token"
,
[
"tokenIdentifier"
]
)
.
index
(
"by_email"
,
[
"email"
]
)
,
}
)
;
Core Helper Functions
Get Current User
// convex/lib/auth.ts
import
{
QueryCtx
,
MutationCtx
}
from
"./_generated/server"
;
import
{
Doc
}
from
"./_generated/dataModel"
;
export
async
function
getCurrentUser
(
ctx
:
QueryCtx
|
MutationCtx
)
:
Promise
<
Doc
<
"users"
{ const identity = await ctx . auth . getUserIdentity ( ) ; if ( ! identity ) { throw new Error ( "Not authenticated" ) ; } const user = await ctx . db . query ( "users" ) . withIndex ( "by_token" , q => q . eq ( "tokenIdentifier" , identity . tokenIdentifier ) ) . unique ( ) ; if ( ! user ) { throw new Error ( "User not found" ) ; } return user ; } export async function getCurrentUserOrNull ( ctx : QueryCtx | MutationCtx ) : Promise < Doc < "users"
| null
{ const identity = await ctx . auth . getUserIdentity ( ) ; if ( ! identity ) { return null ; } return await ctx . db . query ( "users" ) . withIndex ( "by_token" , q => q . eq ( "tokenIdentifier" , identity . tokenIdentifier ) ) . unique ( ) ; } Require Admin export async function requireAdmin ( ctx : QueryCtx | MutationCtx ) : Promise < Doc < "users"
{ const user = await getCurrentUser ( ctx ) ; if ( user . role !== "admin" ) { throw new Error ( "Admin access required" ) ; } return user ; } User Creation/Upsert On First Sign-In // convex/users.ts import { mutation } from "./_generated/server" ; import { v } from "convex/values" ; export const storeUser = mutation ( { args : { } , handler : async ( ctx ) => { const identity = await ctx . auth . getUserIdentity ( ) ; if ( ! identity ) { throw new Error ( "Not authenticated" ) ; } // Check if user exists const existingUser = await ctx . db . query ( "users" ) . withIndex ( "by_token" , q => q . eq ( "tokenIdentifier" , identity . tokenIdentifier ) ) . unique ( ) ; if ( existingUser ) { // Update last seen or other fields await ctx . db . patch ( existingUser . _id , { updatedAt : Date . now ( ) , } ) ; return existingUser . _id ; } // Create new user const userId = await ctx . db . insert ( "users" , { tokenIdentifier : identity . tokenIdentifier , name : identity . name ?? "Anonymous" , email : identity . email ?? "" , pictureUrl : identity . pictureUrl , role : "user" , createdAt : Date . now ( ) , } ) ; return userId ; } , } ) ; Access Control Patterns Owner-Only Access import { mutation } from "./_generated/server" ; import { v } from "convex/values" ; import { getCurrentUser } from "./lib/auth" ; export const updateProfile = mutation ( { args : { name : v . string ( ) , } , handler : async ( ctx , args ) => { const user = await getCurrentUser ( ctx ) ; await ctx . db . patch ( user . _id , { name : args . name , updatedAt : Date . now ( ) , } ) ; } , } ) ; Resource Ownership export const deleteTask = mutation ( { args : { taskId : v . id ( "tasks" ) } , handler : async ( ctx , args ) => { const user = await getCurrentUser ( ctx ) ; const task = await ctx . db . get ( args . taskId ) ; if ( ! task ) { throw new Error ( "Task not found" ) ; } // Check ownership if ( task . userId !== user . _id ) { throw new Error ( "You can only delete your own tasks" ) ; } await ctx . db . delete ( args . taskId ) ; } , } ) ; Team-Based Access // Schema includes membership table export default defineSchema ( { teams : defineTable ( { name : v . string ( ) , ownerId : v . id ( "users" ) , } ) , teamMembers : defineTable ( { teamId : v . id ( "teams" ) , userId : v . id ( "users" ) , role : v . union ( v . literal ( "owner" ) , v . literal ( "member" ) ) , } ) . index ( "by_team" , [ "teamId" ] ) . index ( "by_user" , [ "userId" ] ) . index ( "by_team_and_user" , [ "teamId" , "userId" ] ) , } ) ; // Helper to check team access async function requireTeamAccess ( ctx : MutationCtx , teamId : Id < "teams"
) : Promise < { user : Doc < "users"
, membership : Doc < "teamMembers"
}
{ const user = await getCurrentUser ( ctx ) ; const membership = await ctx . db . query ( "teamMembers" ) . withIndex ( "by_team_and_user" , q => q . eq ( "teamId" , teamId ) . eq ( "userId" , user . _id ) ) . unique ( ) ; if ( ! membership ) { throw new Error ( "You don't have access to this team" ) ; } return { user , membership } ; } // Use in functions export const createProject = mutation ( { args : { teamId : v . id ( "teams" ) , name : v . string ( ) , } , handler : async ( ctx , args ) => { await requireTeamAccess ( ctx , args . teamId ) ; return await ctx . db . insert ( "projects" , { teamId : args . teamId , name : args . name , } ) ; } , } ) ; Public vs Private Queries Public Query (No Auth Required) export const listPublicPosts = query ( { args : { } , handler : async ( ctx ) => { // No auth check - anyone can read return await ctx . db . query ( "posts" ) . withIndex ( "by_published" , q => q . eq ( "published" , true ) ) . collect ( ) ; } , } ) ; Private Query (Auth Required) export const getMyPosts = query ( { args : { } , handler : async ( ctx ) => { const user = await getCurrentUser ( ctx ) ; return await ctx . db . query ( "posts" ) . withIndex ( "by_user" , q => q . eq ( "userId" , user . _id ) ) . collect ( ) ; } , } ) ; Hybrid Query (Optional Auth) export const getPosts = query ( { args : { } , handler : async ( ctx ) => { const user = await getCurrentUserOrNull ( ctx ) ; if ( user ) { // Show all posts including drafts for this user return await ctx . db . query ( "posts" ) . withIndex ( "by_user" , q => q . eq ( "userId" , user . _id ) ) . collect ( ) ; } else { // Show only public posts for anonymous users return await ctx . db . query ( "posts" ) . withIndex ( "by_published" , q => q . eq ( "published" , true ) ) . collect ( ) ; } } , } ) ; Client Setup with WorkOS WorkOS AuthKit provides a complete authentication solution with minimal setup. React/Vite Setup npm install @workos-inc/authkit-react // src/main.tsx import { AuthKitProvider , useAuth } from "@workos-inc/authkit-react" ; import { ConvexReactClient } from "convex/react" ; import { ConvexProvider } from "convex/react" ; const convex = new ConvexReactClient ( import . meta . env . VITE_CONVEX_URL ) ; // Configure Convex to use WorkOS auth convex . setAuth ( useAuth ) ; function App ( ) { return ( < AuthKitProvider
< ConvexProvider client = { convex }
< YourApp /
< / ConvexProvider
< / AuthKitProvider
) ; } Next.js Setup npm install @workos-inc/authkit-nextjs // app/layout.tsx import { AuthKitProvider } from "@workos-inc/authkit-nextjs" ; import { ConvexClientProvider } from "./ConvexClientProvider" ; export default function RootLayout ( { children } : { children : React . ReactNode } ) { return ( < html
< body
< AuthKitProvider
< ConvexClientProvider
{ children } < / ConvexClientProvider
< / AuthKitProvider
< / body
< / html
) ; } // app/ConvexClientProvider.tsx "use client" ; import { ConvexReactClient } from "convex/react" ; import { ConvexProvider } from "convex/react" ; import { useAuth } from "@workos-inc/authkit-nextjs" ; const convex = new ConvexReactClient ( process . env . NEXT_PUBLIC_CONVEX_URL ! ) ; export function ConvexClientProvider ( { children } : { children : React . ReactNode } ) { const { getToken } = useAuth ( ) ; convex . setAuth ( async ( ) => { return await getToken ( ) ; } ) ; return < ConvexProvider client = { convex }
{ children } < / ConvexProvider
; } Environment Variables
.env.local (React/Vite)
VITE_CONVEX_URL
https://your-deployment.convex.cloud VITE_WORKOS_CLIENT_ID = your_workos_client_id
.env.local (Next.js)
NEXT_PUBLIC_CONVEX_URL
https://your-deployment.convex.cloud NEXT_PUBLIC_WORKOS_CLIENT_ID = your_workos_client_id WORKOS_API_KEY = your_workos_api_key WORKOS_COOKIE_PASSWORD = generate_a_random_32_character_string Call storeUser on Sign-In // In your app after user signs in import { useMutation } from "convex/react" ; import { api } from "../convex/_generated/api" ; import { useEffect } from "react" ; import { useAuth } from "@workos-inc/authkit-react" ; function YourApp ( ) { const { user } = useAuth ( ) ; const storeUser = useMutation ( api . users . storeUser ) ; useEffect ( ( ) => { if ( user ) { storeUser ( ) ; } } , [ user , storeUser ] ) ; // ... rest of your app } Alternative Auth Providers If you need to use a different provider, see the Convex auth documentation for: Custom JWT Auth0 Other OAuth providers Checklist Users table with tokenIdentifier index getCurrentUser helper function storeUser mutation for first sign-in Authentication check in all protected functions Authorization check for resource access Clear error messages ("Not authenticated", "Unauthorized") Client auth provider configured (WorkOS, Auth0, etc.)