Convex Adapter for Auth.js (NextAuth) Setup Guide
Learn how to configure Convex as your database adapter for Auth.js See Convex with Auth.js article for more context on using Convex with Auth.js.
Do you know Auth.js and Convex already, and just want to get going with a template? Clone this repo and follow its README:
Installation
The adapter is currently not packaged into Auth.js. You will install it by copying code into your convex
folder and into your Next.js app
folder (even if you’re using the Pages router).
Dependencies
If you don’t already use it, install convex-helpers
, which is used in the adapter code:
npm install convex-helpers
Schema
The adapter uses indexes for efficient reads from the database. Copy the authTables
code to your convex/schema.ts
file. This schema matches the model used by Auth.js.
convex/schema.ts
code
import { defineSchema, defineTable } from "convex/server";
import { Validator, v } from "convex/values";
// The users, accounts, sessions and verificationTokens tables are modeled
// from https://authjs.dev/getting-started/adapters#models
export const userSchema = {
email: v.string(),
name: v.optional(v.string()),
emailVerified: v.optional(v.number()),
image: v.optional(v.string()),
};
export const sessionSchema = {
userId: v.id("users"),
expires: v.number(),
sessionToken: v.string(),
};
export const accountSchema = {
userId: v.id("users"),
type: v.union(
v.literal("email"),
v.literal("oidc"),
v.literal("oauth"),
v.literal("webauthn"),
),
provider: v.string(),
providerAccountId: v.string(),
refresh_token: v.optional(v.string()),
access_token: v.optional(v.string()),
expires_at: v.optional(v.number()),
token_type: v.optional(v.string() as Validator<Lowercase<string>>),
scope: v.optional(v.string()),
id_token: v.optional(v.string()),
session_state: v.optional(v.string()),
};
export const verificationTokenSchema = {
identifier: v.string(),
token: v.string(),
expires: v.number(),
};
export const authenticatorSchema = {
credentialID: v.string(),
userId: v.id("users"),
providerAccountId: v.string(),
credentialPublicKey: v.string(),
counter: v.number(),
credentialDeviceType: v.string(),
credentialBackedUp: v.boolean(),
transports: v.optional(v.string()),
};
const authTables = {
users: defineTable(userSchema).index("email", ["email"]),
sessions: defineTable(sessionSchema)
.index("sessionToken", ["sessionToken"])
.index("userId", ["userId"]),
accounts: defineTable(accountSchema)
.index("providerAndAccountId", ["provider", "providerAccountId"])
.index("userId", ["userId"]),
verificationTokens: defineTable(verificationTokenSchema).index(
"identifierToken",
["identifier", "token"],
),
authenticators: defineTable(authenticatorSchema)
.index("userId", ["userId"])
.index("credentialID", ["credentialID"]),
};
export default defineSchema({
...authTables,
// your other tables
// or pass `strictTableNameTypes: false`
// in the second argument argument to `defineSchema`
});
Endpoints
Copy all of the following code to convex/authAdapter.ts
. This is the Convex portion of the adapter. It uses convex-helpers
to define custom query and mutation constructor functions that ensure all the endpoints in this file are secured. The endpoints match 1:1 the Auth.js Adapter
interface.
convex/authAdapter.ts
code
import { partial } from "convex-helpers/validators";
import {
customMutation,
customQuery,
} from "convex-helpers/server/customFunctions";
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import {
accountSchema,
authenticatorSchema,
sessionSchema,
userSchema,
verificationTokenSchema,
} from "./schema";
const adapterQuery = customQuery(query, {
args: { secret: v.string() },
input: async (_ctx, { secret }) => {
checkSecret(secret);
return { ctx: {}, args: {} };
},
});
const adapterMutation = customMutation(mutation, {
args: { secret: v.string() },
input: async (_ctx, { secret }) => {
checkSecret(secret);
return { ctx: {}, args: {} };
},
});
function checkSecret(secret: string) {
if (process.env.CONVEX_AUTH_ADAPTER_SECRET === undefined) {
throw new Error(
"Missing CONVEX_AUTH_ADAPTER_SECRET Convex environment variable",
);
}
if (secret !== process.env.CONVEX_AUTH_ADAPTER_SECRET) {
throw new Error("Adapter API called without correct secret value");
}
}
export const createAuthenticator = adapterMutation({
args: { authenticator: v.object(authenticatorSchema) },
handler: async (ctx, args) => {
return await ctx.db.insert("authenticators", args.authenticator);
},
});
export const createSession = adapterMutation({
args: { session: v.object(sessionSchema) },
handler: async (ctx, { session }) => {
return await ctx.db.insert("sessions", session);
},
});
export const createUser = adapterMutation({
args: { user: v.object(userSchema) },
handler: async (ctx, { user }) => {
return await ctx.db.insert("users", user);
},
});
export const createVerificationToken = adapterMutation({
args: { verificationToken: v.object(verificationTokenSchema) },
handler: async (ctx, { verificationToken }) => {
return await ctx.db.insert("verificationTokens", verificationToken);
},
});
export const deleteSession = adapterMutation({
args: { sessionToken: v.string() },
handler: async (ctx, args) => {
const session = await ctx.db
.query("sessions")
.withIndex("sessionToken", (q) => q.eq("sessionToken", args.sessionToken))
.unique();
if (session === null) {
return null;
}
await ctx.db.delete(session._id);
return session;
},
});
export const deleteUser = adapterMutation({
args: { id: v.id("users") },
handler: async (ctx, { id }) => {
const user = await ctx.db.get(id);
if (user === null) {
return null;
}
await ctx.db.delete(id);
const sessions = await ctx.db
.query("sessions")
.withIndex("userId", (q) => q.eq("userId", id))
.collect();
for (const session of sessions) {
await ctx.db.delete(session._id);
}
const accounts = await ctx.db
.query("accounts")
.withIndex("userId", (q) => q.eq("userId", id))
.collect();
for (const account of accounts) {
await ctx.db.delete(account._id);
}
return user;
},
});
export const getAccount = adapterQuery({
args: { provider: v.string(), providerAccountId: v.string() },
handler: async (ctx, { provider, providerAccountId }) => {
return await ctx.db
.query("accounts")
.withIndex("providerAndAccountId", (q) =>
q.eq("provider", provider).eq("providerAccountId", providerAccountId),
)
.unique();
},
});
export const getAuthenticator = adapterQuery({
args: { credentialID: v.string() },
handler: async (ctx, { credentialID }) => {
return await ctx.db
.query("authenticators")
.withIndex("credentialID", (q) => q.eq("credentialID", credentialID))
.unique();
},
});
export const getSessionAndUser = adapterQuery({
args: { sessionToken: v.string() },
handler: async (ctx, { sessionToken }) => {
const session = await ctx.db
.query("sessions")
.withIndex("sessionToken", (q) => q.eq("sessionToken", sessionToken))
.unique();
if (session === null) {
return null;
}
const user = await ctx.db.get(session.userId);
if (user === null) {
return null;
}
return { session, user };
},
});
export const getUser = adapterQuery({
args: { id: v.id("users") },
handler: async (ctx, { id }) => {
return await ctx.db.get(id);
},
});
export const getUserByAccount = adapterQuery({
args: { provider: v.string(), providerAccountId: v.string() },
handler: async (ctx, { provider, providerAccountId }) => {
const account = await ctx.db
.query("accounts")
.withIndex("providerAndAccountId", (q) =>
q.eq("provider", provider).eq("providerAccountId", providerAccountId),
)
.unique();
if (account === null) {
return null;
}
return await ctx.db.get(account.userId);
},
});
export const getUserByEmail = adapterQuery({
args: { email: v.string() },
handler: async (ctx, { email }) => {
return await ctx.db
.query("users")
.withIndex("email", (q) => q.eq("email", email))
.unique();
},
});
export const linkAccount = adapterMutation({
args: { account: v.object(accountSchema) },
handler: async (ctx, { account }) => {
const id = await ctx.db.insert("accounts", account);
return await ctx.db.get(id);
},
});
export const listAuthenticatorsByUserId = adapterQuery({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
return await ctx.db
.query("authenticators")
.withIndex("userId", (q) => q.eq("userId", userId))
.collect();
},
});
export const unlinkAccount = adapterMutation({
args: { provider: v.string(), providerAccountId: v.string() },
handler: async (ctx, { provider, providerAccountId }) => {
const account = await ctx.db
.query("accounts")
.withIndex("providerAndAccountId", (q) =>
q.eq("provider", provider).eq("providerAccountId", providerAccountId),
)
.unique();
if (account === null) {
return null;
}
await ctx.db.delete(account._id);
return account;
},
});
export const updateAuthenticatorCounter = adapterMutation({
args: { credentialID: v.string(), newCounter: v.number() },
handler: async (ctx, { credentialID, newCounter }) => {
const authenticator = await ctx.db
.query("authenticators")
.withIndex("credentialID", (q) => q.eq("credentialID", credentialID))
.unique();
if (authenticator === null) {
throw new Error(
`Authenticator not found for credentialID: ${credentialID}`,
);
}
await ctx.db.patch(authenticator._id, { counter: newCounter });
return { ...authenticator, counter: newCounter };
},
});
export const updateSession = adapterMutation({
args: {
session: v.object({
expires: v.number(),
sessionToken: v.string(),
}),
},
handler: async (ctx, { session }) => {
const existingSession = await ctx.db
.query("sessions")
.withIndex("sessionToken", (q) =>
q.eq("sessionToken", session.sessionToken),
)
.unique();
if (existingSession === null) {
return null;
}
await ctx.db.patch(existingSession._id, session);
},
});
export const updateUser = adapterMutation({
args: {
user: v.object({
id: v.id("users"),
...partial(userSchema),
}),
},
handler: async (ctx, { user: { id, ...data } }) => {
const user = await ctx.db.get(id);
if (user === null) {
return;
}
await ctx.db.patch(user._id, data);
},
});
export const useVerificationToken = adapterMutation({
args: { identifier: v.string(), token: v.string() },
handler: async (ctx, { identifier, token }) => {
const verificationToken = await ctx.db
.query("verificationTokens")
.withIndex("identifierToken", (q) =>
q.eq("identifier", identifier).eq("token", token),
)
.unique();
if (verificationToken === null) {
return null;
}
await ctx.db.delete(verificationToken._id);
return verificationToken;
},
});
Interface
Copy all the following code tom app/ConvexAdapter.ts
. This is the Auth.js/Next.js portion of the adapter. It uses fetchMutation
and fetchQuery
to call your Convex backend from the Next.js server.
app/ConvexAdapter.ts
code
import type {
Adapter,
AdapterAccount,
AdapterAuthenticator,
AdapterSession,
AdapterUser,
VerificationToken,
} from "@auth/core/adapters";
import { fetchMutation, fetchQuery } from "convex/nextjs";
import { FunctionArgs, FunctionReference } from "convex/server";
import { api } from "../convex/_generated/api";
import { Doc, Id } from "../convex/_generated/dataModel";
type User = AdapterUser & { id: Id<"users"> };
type Session = AdapterSession & { userId: Id<"users"> };
type Account = AdapterAccount & { userId: Id<"users"> };
type Authenticator = AdapterAuthenticator & { userId: Id<"users"> };
export const ConvexAdapter: Adapter = {
async createAuthenticator(authenticator: Authenticator) {
await callMutation(api.authAdapter.createAuthenticator, { authenticator });
return authenticator;
},
async createSession(session: Session) {
const id = await callMutation(api.authAdapter.createSession, {
session: toDB(session),
});
return { ...session, id };
},
async createUser({ id: _, ...user }: User) {
const id = await callMutation(api.authAdapter.createUser, {
user: toDB(user),
});
return { ...user, id };
},
async createVerificationToken(verificationToken: VerificationToken) {
await callMutation(api.authAdapter.createVerificationToken, {
verificationToken: toDB(verificationToken),
});
return verificationToken;
},
async deleteSession(sessionToken) {
return maybeSessionFromDB(
await callMutation(api.authAdapter.deleteSession, {
sessionToken,
}),
);
},
async deleteUser(id: Id<"users">) {
return maybeUserFromDB(
await callMutation(api.authAdapter.deleteUser, { id }),
);
},
async getAccount(providerAccountId, provider) {
return await callQuery(api.authAdapter.getAccount, {
provider,
providerAccountId,
});
},
async getAuthenticator(credentialID) {
return await callQuery(api.authAdapter.getAuthenticator, { credentialID });
},
async getSessionAndUser(sessionToken) {
const result = await callQuery(api.authAdapter.getSessionAndUser, {
sessionToken,
});
if (result === null) {
return null;
}
const { user, session } = result;
return { user: userFromDB(user), session: sessionFromDB(session) };
},
async getUser(id: Id<"users">) {
return maybeUserFromDB(await callQuery(api.authAdapter.getUser, { id }));
},
async getUserByAccount({ provider, providerAccountId }) {
return maybeUserFromDB(
await callQuery(api.authAdapter.getUserByAccount, {
provider,
providerAccountId,
}),
);
},
async getUserByEmail(email) {
return maybeUserFromDB(
await callQuery(api.authAdapter.getUserByEmail, { email }),
);
},
async linkAccount(account: Account) {
return await callMutation(api.authAdapter.linkAccount, { account });
},
async listAuthenticatorsByUserId(userId: Id<"users">) {
return await callQuery(api.authAdapter.listAuthenticatorsByUserId, {
userId,
});
},
async unlinkAccount({ provider, providerAccountId }) {
return (
(await callMutation(api.authAdapter.unlinkAccount, {
provider,
providerAccountId,
})) ?? undefined
);
},
async updateAuthenticatorCounter(credentialID, newCounter) {
return await callMutation(api.authAdapter.updateAuthenticatorCounter, {
credentialID,
newCounter,
});
},
async updateSession(session: Session) {
return await callMutation(api.authAdapter.updateSession, {
session: toDB(session),
});
},
async updateUser(user: User) {
await callMutation(api.authAdapter.updateUser, { user: toDB(user) });
return user;
},
async useVerificationToken({ identifier, token }) {
return maybeVerificationTokenFromDB(
await callMutation(api.authAdapter.useVerificationToken, {
identifier,
token,
}),
);
},
};
/// Helpers
function callQuery<Query extends FunctionReference<"query">>(
query: Query,
args: Omit<FunctionArgs<Query>, "secret">,
) {
return fetchQuery(query, addSecret(args) as any);
}
function callMutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation,
args: Omit<FunctionArgs<Mutation>, "secret">,
) {
return fetchMutation(mutation, addSecret(args) as any);
}
if (process.env.CONVEX_AUTH_ADAPTER_SECRET === undefined) {
throw new Error("Missing CONVEX_AUTH_ADAPTER_SECRET environment variable");
}
function addSecret(args: Record<string, any>) {
return { ...args, secret: process.env.CONVEX_AUTH_ADAPTER_SECRET! };
}
function maybeUserFromDB(user: Doc<"users"> | null) {
if (user === null) {
return null;
}
return userFromDB(user);
}
function userFromDB(user: Doc<"users">) {
return {
...user,
id: user._id,
emailVerified: maybeDate(user.emailVerified),
};
}
function maybeSessionFromDB(session: Doc<"sessions"> | null) {
if (session === null) {
return null;
}
return sessionFromDB(session);
}
function sessionFromDB(session: Doc<"sessions">) {
return { ...session, id: session._id, expires: new Date(session.expires) };
}
function maybeVerificationTokenFromDB(
verificationToken: Doc<"verificationTokens"> | null,
) {
if (verificationToken === null) {
return null;
}
return verificationTokenFromDB(verificationToken);
}
function verificationTokenFromDB(verificationToken: Doc<"verificationTokens">) {
return { ...verificationToken, expires: new Date(verificationToken.expires) };
}
function maybeDate(value: number | undefined) {
return value === undefined ? null : new Date(value);
}
function toDB<T extends object>(
obj: T,
): {
[K in keyof T]: T[K] extends Date
? number
: null extends T[K]
? undefined
: T[K];
} {
const result: any = {};
for (const key in obj) {
const value = obj[key];
result[key] =
value instanceof Date
? value.getTime()
: value === null
? undefined
: value;
}
return result;
}
Environment variables
Both in Next.js and in Convex set CONVEX_AUTH_ADAPTER_SECRET
to the same secret value.
The adapter endpoints are public, but must be called only by your Next.js server code. Since you control both the Next.js server and the Convex backend, this can be done with a simple shared secret stored in an environment variable on both ends.
Generate a random value (with openssl rand -base64 33
or npx auth secret
or pick a long, unique phrase). Set it as CONVEX_AUTH_ADAPTER_SECRET
in .env.local
.
Then also paste it into your Convex deployment’s settings or set it from the CLI with npx convex env set CONVEX_AUTH_ADAPTER_SECRET somevalue
.
When you deploy to production, remember to set this on your prod deployment with npx convex env --prod set ...
or on the prod dashboard, as well as in your hosting provider’s settings (e.g. Netlify or Vercel’s environment variables).
Configuration
Use the ConvexAdapter
object as the adapter
in your Auth.js config:
import NextAuth from "next-auth"
import { ConvexAdapter } from "@/app/ConvexAdapter";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [],
adapter: ConvexAdapter,
})
Authenticating Convex function calls
Convex uses the Open ID Connect protocol for authentication. It requires that the caller provides a JWT signed with a private key. The JWT signature can be verified using a public key normally exposed by the server that issued the JWT.
Since you control both the Next.js server and the Convex backend, you can have your Next.js server issue the JWT and your Convex backend expose the public key1.
Dependencies
Install the jose
library for creating public-private key pairs and signing JWTs:
npm install jose
Generate the keys
Save this code as generateKeys.mjs
in your project directory:
generateKeys.mjs
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
const keys = await generateKeyPair("RS256");
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
process.stdout.write(
`CONVEX_AUTH_PRIVATE_KEY="${privateKey.replace(/\n/g, "\\n")}"`,
);
process.stdout.write("\n\n");
process.stdout.write(`JWKS=${jwks}`);
process.stdout.write("\n");
And run it with Node to generate two KEY=value
environment variables:
node generateKeys.mjs
The first environment variable is the private key (CONVEX_AUTH_PRIVATE_KEY="-----BEGIN PRIVATE KEY--—…
). Configure your Next.js server with it (i.e. paste it in your .env.local
file).
The second is the public key included in a JSON Web Key Set (JWKS='{"keys”…
). Configure your Convex backend with it (i.e. paste it into your Convex deployment’s settings).
Expose the public key
Add the two endpoints as shown to your http.ts
file:
Example http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/.well-known/openid-configuration",
method: "GET",
handler: httpAction(async () => {
return new Response(
JSON.stringify({
issuer: process.env.CONVEX_SITE_URL,
jwks_uri: process.env.CONVEX_SITE_URL + "/.well-known/jwks.json",
authorization_endpoint:
process.env.CONVEX_SITE_URL + "/oauth/authorize",
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
},
);
}),
});
http.route({
path: "/.well-known/jwks.json",
method: "GET",
handler: httpAction(async () => {
if (process.env.JWKS === undefined) {
throw new Error("Missing JWKS Convex environment variable");
}
return new Response(process.env.JWKS, {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
},
});
}),
});
export default http;
Modify the Convex auth config
Add a new provider to your convex/auth.config.ts
:
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};
Because domain
is set to the value of the built-in CONVEX_SITE_URL
environment variable, you’re telling your Convex backend to check with itself for the public key it should use to validate JWTs. The applicationID
value must match the “audience” set on the JWT.
Issue the JWT
In the session callback of the Auth.js config create the JWT and add it to the session
object. This will make the JWT available on both the server and the client:
import { ConvexAdapter } from "@/app/ConvexAdapter";
import { SignJWT, importPKCS8 } from "jose";
import NextAuth from "next-auth";
const CONVEX_SITE_URL = process.env.NEXT_PUBLIC_CONVEX_URL!.replace(
/.cloud$/,
".site",
);
export const { handlers, signIn, signOut, auth } = NextAuth({
debug: true,
providers: [
// ... configured providers
],
adapter: ConvexAdapter,
callbacks: {
async session({ session }) {
const privateKey = await importPKCS8(
process.env.CONVEX_AUTH_PRIVATE_KEY!,
"RS256",
);
const convexToken = await new SignJWT({
sub: session.userId,
})
.setProtectedHeader({ alg: "RS256" })
.setIssuedAt()
.setIssuer(CONVEX_SITE_URL)
.setAudience("convex")
.setExpirationTime("1h")
.sign(privateKey);
return { ...session, convexToken };
},
},
});
declare module "next-auth" {
interface Session {
convexToken: string;
}
}
Provide the JWT to the Convex React client
Replace your ConvexProvider
with ConvexProviderWithAuth
as shown:
"use client";
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
import { SessionProvider, useSession } from "next-auth/react";
import { Session } from "next-auth";
import { ReactNode, useMemo } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function ConvexClientProvider({
children,
session,
}: {
children: ReactNode;
session: Session | null;
}) {
return (
<SessionProvider session={session}>
<ConvexProviderWithAuth client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithAuth>
</SessionProvider>
);
}
function useAuth() {
const { data: session, update } = useSession();
const convexToken = convexTokenFromSession(session);
return useMemo(
() => ({
isLoading: false,
isAuthenticated: session !== null,
fetchAccessToken: async ({
forceRefreshToken,
}: {
forceRefreshToken: boolean;
}) => {
if (forceRefreshToken) {
const session = await update();
return convexTokenFromSession(session);
}
return convexToken;
},
}),
// We only care about the user changes, and don't want to
// bust the memo when we fetch a new token.
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(session?.user)],
);
}
function convexTokenFromSession(session: Session | null): string | null {
return session?.convexToken ?? null;
}
Make sure to pass the session
object from server to the client component:
App Router example, app/loggedin/layout.tsx
(in repo)
import ConvexClientProvider from "@/app/ConvexClientProvider";
import { auth, signOut } from "@/auth";
import { ReactNode } from "react";
export default async function LoggedInLayout({
children,
}: {
children: ReactNode;
}) {
const session = await auth();
return (
<>
<SignOut />
<ConvexClientProvider session={session}>{children}</ConvexClientProvider>
</>
);
}
function SignOut() {/*...*/}
Pages Router example, pages/loggedin/index.tsx
(in repo)
import ConvexClientProvider from "@/app/ConvexClientProvider";
import { auth } from "@/auth";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/convex/_generated/api";
import { useMutation, useQuery } from "convex/react";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { Session } from "next-auth";
export const getServerSideProps = (async (ctx) => {
const session = await auth(ctx);
return {
props: {
session,
},
};
}) satisfies GetServerSideProps<{ session: Session | null }>;
export default function Page({
session,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<ConvexClientProvider session={session}>
<Content />
</ConvexClientProvider>
);
}
Footnotes
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.