AiVault uses Clerk for authentication and integrates it with Convex for secure server-side identity verification.
Authentication Flow
Clerk Configuration
Auth Config
Convex is configured to accept JWTs from Clerk:
export default {
providers: [
{
domain: "https://hopeful-ringtail-54.clerk.accounts.dev",
applicationID: "convex",
},
],
};
This tells Convex to trust JWT tokens issued by the specified Clerk domain.
Environment Variables
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Convex
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
CONVEX_DEPLOY_KEY=prod:your-project|...
# Admin users (comma-separated Clerk user IDs)
NEXT_PUBLIC_ADMIN_USER_IDS=user_abc123,user_xyz789
Protected Routes
Middleware
Next.js middleware protects routes before they load:
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher([
"/dashboard(.*)",
"/submit(.*)",
"/admin(.*)",
]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
"/(api|trpc)(.*)",
],
};
How It Works
- User visits
/dashboard
- Middleware runs before page loads
isProtectedRoute() checks if path matches patterns
- If protected and not authenticated, redirects to Clerk sign-in
- If authenticated, allows request to continue
Middleware runs on every request, making it efficient for auth checks without database queries.
Client-Side Auth
ConvexProviderWithClerk
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
Layout Integration
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="min-h-screen flex flex-col font-sans antialiased selection:bg-primary/30">
<Providers>
<Navbar />
<main className="flex-1 flex flex-col">{children}</main>
<Footer />
</Providers>
<Analytics />
</body>
</html>
);
}
ConvexProviderWithClerk automatically attaches Clerk JWT tokens to all Convex queries and mutations.
Server-Side Identity
Get User Identity
In Convex functions, verify the authenticated user:
async function getIdentity(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
return identity;
}
Identity Object
interface UserIdentity {
subject: string; // Clerk user ID (e.g., "user_abc123")
tokenIdentifier: string;
email?: string;
name?: string;
pictureUrl?: string;
// ... other Clerk user fields
}
Usage in Queries
export const getSubmittedTools = query({
args: {},
handler: async (ctx: QueryCtx) => {
const identity = await getIdentity(ctx);
// Filter by authenticated user
return await ctx.db
.query("tools")
.withIndex("by_submittedBy", (q) => q.eq("submittedBy", identity.subject))
.collect();
},
});
Usage in Mutations
export const submitTool = mutation({
handler: async (ctx: MutationCtx, args: any) => {
const identity = await getIdentity(ctx);
await ctx.db.insert("tools", {
// ... tool fields
submittedBy: identity.subject, // Store Clerk user ID
createdAt: Date.now(),
});
},
});
Authorization
Admin-Only Functions
const getAdminIds = () => (process.env.NEXT_PUBLIC_ADMIN_USER_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
async function checkAdmin(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
if (!getAdminIds().includes(identity.subject)) {
throw new Error("Unauthorized: Admin access required");
}
return identity;
}
Protected Admin Query
export const getPendingTools = query({
handler: async (ctx: QueryCtx) => {
await checkAdmin(ctx); // Throws if not admin
return await ctx.db
.query("tools")
.withIndex("by_approved", (q) => q.eq("approved", false))
.collect();
},
});
Protected Admin Mutation
export const approveTool = mutation({
args: { toolId: v.id("tools") },
handler: async (ctx: MutationCtx, args) => {
await checkAdmin(ctx); // Verify admin
await ctx.db.patch(args.toolId, { approved: true });
return { success: true };
},
});
Always check authorization on the server-side (Convex functions). Never rely on client-side checks.
UI Components
import { SignInButton, SignedIn, SignedOut, UserButton } from "@clerk/nextjs";
function Navbar() {
return (
<nav>
<SignedOut>
<SignInButton mode="modal">
<button>Sign In</button>
</SignInButton>
</SignedOut>
<SignedIn>
<UserButton afterSignOutUrl="/" />
</SignedIn>
</nav>
);
}
Conditional Rendering
import { useUser } from "@clerk/nextjs";
function SubmitButton() {
const { isSignedIn, user } = useUser();
if (!isSignedIn) {
return <SignInButton>Sign in to submit</SignInButton>;
}
return <button>Submit Tool</button>;
}
Admin-Only UI
import { useUser } from "@clerk/nextjs";
const ADMIN_IDS = process.env.NEXT_PUBLIC_ADMIN_USER_IDS?.split(",") || [];
function AdminPanel() {
const { user } = useUser();
if (!user || !ADMIN_IDS.includes(user.id)) {
return null; // Hide from non-admins
}
return <div>Admin Dashboard</div>;
}
UI-level checks are for UX only. Always enforce authorization in Convex functions.
JWT Token Flow
- User signs in → Clerk issues JWT token
- Token stored → Browser stores token (httpOnly cookie)
- Request made → ConvexProviderWithClerk attaches token to request
- Convex receives → Extracts JWT from request headers
- Convex verifies → Validates JWT with Clerk’s public key
- Identity returned →
ctx.auth.getUserIdentity() returns user data
// Client-side
const tools = useQuery(api.tools.getSubmittedTools, {});
// Automatically includes JWT in request
// Server-side (Convex)
export const getSubmittedTools = query({
handler: async (ctx) => {
// Convex has already verified JWT
const identity = await ctx.auth.getUserIdentity();
// identity.subject is the Clerk user ID
},
});
Security Best Practices
Always verify user identity on the server (Convex functions) using ctx.auth.getUserIdentity().// ✗ BAD - trusts client
mutation({
args: { userId: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tools", {
submittedBy: args.userId // Client could fake this!
});
}
});
// ✓ GOOD - verifies on server
mutation({
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
await ctx.db.insert("tools", {
submittedBy: identity.subject // Server-verified
});
}
});
Protect sensitive queries
Throw errors for unauthenticated or unauthorized access.export const getPrivateData = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Must be signed in");
// Return user-specific data
}
});
Store user IDs, not emails
Use Clerk’s user.id (the subject field) as the primary identifier. Emails can change.submittedBy: identity.subject // "user_abc123" - immutable
Store admin user IDs in environment variables, not in code or database.NEXT_PUBLIC_ADMIN_USER_IDS=user_abc123,user_xyz789
Testing Authentication
Local Development
- Create Clerk account at clerk.com
- Create new application
- Copy API keys to
.env.local
- Run
npm run dev
- Sign in through UI
Testing Protected Routes
# Not signed in
curl http://localhost:3000/dashboard
# → Redirects to Clerk sign-in
# Signed in
curl -H "Authorization: Bearer <clerk-jwt>" http://localhost:3000/dashboard
# → Returns protected content
Testing Convex Functions
import { ConvexTestingHelper } from "convex-test";
test("authenticated query", async () => {
const t = new ConvexTestingHelper();
// Mock authenticated user
t.setAuth({ subject: "user_test123" });
const tools = await t.query(api.tools.getSubmittedTools, {});
expect(tools).toBeDefined();
});
Troubleshooting
- Check that
ConvexProviderWithClerk wraps your app
- Verify Clerk environment variables are set
- Ensure
convex/auth.config.js domain matches Clerk domain
- Check user ID is in
NEXT_PUBLIC_ADMIN_USER_IDS
- Verify comma-separated format (no spaces)
- Restart dev server after changing
.env.local
Middleware not protecting routes
- Check
matcher in middleware.ts includes your route
- Verify
isProtectedRoute pattern matches
- Clear Next.js cache:
rm -rf .next
Next Steps
Convex Backend
Learn how to use ctx.auth in queries and mutations
Tech Stack
Understand the full technology stack