Auth

Doc Status: Excellent | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components

Why This Matters

Convex Auth (@convex-dev/auth) replaced Clerk on 2026-05-08. It runs entirely inside the Convex deployment — no third-party JWT issuer, no external auth service to trust. The library owns the users / authAccounts / authSessions / authVerificationCodes / authVerifiers tables and exposes sign-in / sign-out hooks for the client.

Providers

Convex Auth supports three provider types. Configure them in convex/auth.ts:
// packages/backend/convex/auth.ts
import { Password } from '@convex-dev/auth/providers/Password';
import Google from '@auth/core/providers/google';

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [Password, Google /* , ResendOTP */],
});
ProviderStatusSetup Required
PasswordActiveNone
Google OAuthActiveAUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET via npx convex env set
Email OTP (Resend)DisabledUncomment ResendOTP import + AUTH_RESEND_KEY
The Google OAuth client is configured in Google Cloud Console with redirect URI https://<deployment>.convex.site/api/auth/callback/google. See .claude/rules/auth/auth-google-oauth.md for the full setup guide.

Files

convex/auth.ts — Provider Configuration

// packages/backend/convex/auth.ts
import { Password } from '@convex-dev/auth/providers/Password';
import Google from '@auth/core/providers/google';
import { convexAuth } from '@convex-dev/auth/server';

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [Password, Google],
});

convex/auth.config.ts — App Identity

// packages/backend/convex/auth.config.ts
export default {
  providers: [
    {
      domain: process.env.CONVEX_SITE_URL,
      applicationID: 'convex',
    },
  ],
};

convex/http.ts — HTTP Routes

The httpRouter wires auth library routes (/api/auth/signin/..., /api/auth/callback/..., /.well-known/openid-configuration) automatically:
// packages/backend/convex/http.ts
import { httpRouter } from 'convex/server';
import { auth } from './auth';

const http = httpRouter();
auth.addHttpRoutes(http);
export default http;

convex/authEmailResend.ts — Email OTP Provider

Disabled by default. To enable, uncomment the import in auth.ts and set AUTH_RESEND_KEY. Uses Resend for transactional email with 15-minute OTP codes:
npx convex env set AUTH_RESEND_KEY 're_...'
npx convex env set AUTH_EMAIL_FROM 'WonderSound <[email protected]>'

Schema

Spread authTables into defineSchema and extend the users table with a role field:
// packages/backend/convex/schema.ts
import { defineSchema } from 'convex/server';
import { authTables } from '@convex-dev/auth/server';
import { v } from 'convex/values';

export default defineSchema({
  ...authTables,

  users: defineTable({
    // Fields from authTables.users (email, name, image, ...) plus:
    role: v.optional(v.union(v.literal('admin'), v.literal('staff'), v.literal('student'))),
  }).index('email', ['email']),
});

Server-Side

Get Current User ID

import { getAuthUserId } from '@convex-dev/auth/server';

export const myProfile = query({
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return null;
    return await ctx.db.get(userId);
  },
});

Get Full Identity Claims

const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('UNAUTHENTICATED');
// identity.subject — stable user id (matches Id<'users'>)
// identity.email, identity.tokenIdentifier — claims

RBAC — Check Role

import { getAuthUserId } from '@convex-dev/auth/server';

export const adminOnly = mutation({
  args: { ... },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error('UNAUTHENTICATED');

    const me = await ctx.db.get(userId);
    if (me?.role !== 'admin') throw new Error('FORBIDDEN');

    // Admin work...
  },
});

Client-Side

Web (Next.js)

// apps/admin/src/components/providers/ConvexClientProvider.tsx
'use client';
import { ConvexAuthNextjsProvider } from '@convex-dev/auth/nextjs';
import { ConvexReactClient } from 'convex/react';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
  return <ConvexAuthNextjsProvider client={convex}>{children}</ConvexAuthNextjsProvider>;
}

Mobile (Expo)

// apps/mobile/src/lib/ConvexClientProvider.tsx
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { ConvexReactClient } from 'convex/react';
import * as SecureStore from 'expo-secure-store';

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);

const secureStorage = {
  getItem: SecureStore.getItemAsync,
  setItem: SecureStore.setItemAsync,
  removeItem: SecureStore.deleteItemAsync,
};

export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
  return (
    <ConvexAuthProvider client={convex} storage={secureStorage}>
      {children}
    </ConvexAuthProvider>
  );
}
Tokens are persisted in expo-secure-store (Keychain on iOS, EncryptedSharedPreferences on Android).

Sign-In / Sign-Out Form

'use client';
import { useAuthActions } from '@convex-dev/auth/react';
import { useState } from 'react';

export function SignInForm() {
  const { signIn, signOut } = useAuthActions();
  const [flow, setFlow] = useState<'signIn' | 'signUp'>('signIn');

  return (
    <form
      onSubmit={async (e) => {
        e.preventDefault();
        const formData = new FormData(e.currentTarget);
        await signIn('password', {
          email: formData.get('email') as string,
          password: formData.get('password') as string,
          flow,
        });
      }}
    >
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">{flow === 'signIn' ? 'Sign in' : 'Sign up'}</button>
    </form>
  );
}

Webhook Auth (CF Workers)

Webhook workers (apps/lark-sync, apps/meta-conversions-worker) authenticate via shared secret, not Convex Auth:
app.post('/webhook/lark', async (c) => {
  const secret = c.req.header('x-lark-signature');
  if (secret !== env.LARK_WEBHOOK_SECRET) {
    return c.json({ error: 'Unauthorized' }, 401);
  }
  return handleLarkWebhook(c);
});

Environment Variables

VariableWhere setNotes
JWT_PRIVATE_KEYConvex env (set by setup)RSA private key for token signing
JWKSConvex env (set by setup)Public JWKS for verification
CONVEX_SITE_URLConvex (auto-set)Convex HTTP actions URL
SITE_URLConvex env (manual)App URL for OAuth callbacks
AUTH_GOOGLE_IDConvex envGoogle OAuth client ID
AUTH_GOOGLE_SECRETConvex envGoogle OAuth client secret
AUTH_RESEND_KEYConvex envResend API key (for email OTP)
AUTH_EMAIL_FROMConvex envSender email address
One-time setup: cd packages/backend && npx @convex-dev/auth