Jamal Lyons's avatar
Jamal Lyons
8 minutes ago

Authentication Best Practices: Convex, Clerk and Next.js

Authentication Best Practices: Convex, Clerk and NextJs

Authentication Best Practices: Convex, Clerk and NextJs

Authentication is the backbone of any full-stack application, but it’s also one of the easiest places to introduce subtle, hard-to-debug security flaws. As developers, we aim to build secure, reliable systems, but scaling an app to a global audience presents unique challenges—especially regarding authentication.

At ClarityText, where we use Convex in production, we ran into unexpected authentication issues that affected a significant number of users. The problem stemmed from how we handled authentication checks in our client-side application.

Our app relies on the useConvexAuth() hook to verify user sessions on the client. The useConvexAuth() hook returns a promise that needs to be awaited—meaning other React hooks, including Convex queries, can start running before authentication is fully validated. The problem arose from the unpredictability of whether authentication would complete before a Convex query executed. This introduced a race condition where unauthenticated requests could slip through, causing security risks and a frustrating user experience.

In this post, I’ll share what we learned about Convex authentication, how it integrates with Clerk, and best practices for handling authentication effectively. We’ll break down race conditions—what they are, why they happen, and the risks they pose. Then, I’ll introduce three key rules to help you prevent these issues when using Convex and Clerk. To wrap things up, I’ll provide custom hooks that you can easily implement to create safer, more reliable authentication flows in your applications.

Where Does Authentication Belong in Your Application?

Authentication isn’t just about verifying users—it’s about ensuring security at every layer of your application. When building with Convex, Next.js, and Clerk (or similar tools), authentication needs to be considered at three key levels:

  1. Server-side Authentication – Traditionally, authentication is handled at the backend using middleware. In frameworks like Next.js, middleware runs before API requests are processed, allowing authentication checks to happen early. Clerk, for example, uses middleware to validate user sessions before they reach API routes.
  2. Client-side Authentication – Many web apps require real-time interactions, meaning authentication must also be handled by the client. React hooks like useAuth() from Clerk help verify user sessions in the browser, but client-side authentication alone isn’t secure since frontend code can be manipulated. It’s useful for UI state but shouldn’t be solely relied on for access control.
  3. Database Authentication (Convex) – In a typical backend setup, authentication checks happen at the API level before interacting with a database. But with Convex, your backend is a database and public API. That means authentication checks must be enforced within Convex functions to ensure unauthorized users can’t read or write data.

Each of these layers plays a crucial role in securing your application. Failing to authenticate users at any of them can lead to vulnerabilities, especially in real-time applications where database queries might execute before authentication is fully validated.

Implementing Authentication Correctly

When working with authentication, it’s easy to assume that once a user is authenticated at one layer of the application, they’re secure everywhere. But with Next.js and Convex, authentication needs to be explicitly handled at multiple points—especially in applications that mix server and client components.

The Hidden Authentication Risk in Next.js

Next.js makes it easy to secure API routes and server components using middleware. However, if a server component renders a client component that doesn’t properly handle authentication, it can expose parts of your app to unauthorized users.

Consider this example:

Server.tsx

1import { ClientComponent } from './client-component';
2
3export const ServerComponent = () => {
4  const user_session = { userId: 'user_123' };   // fetch this data from convex or a nextjs cookie
5
6  return <ClientComponent session={user_session} />;
7};
8
9export default ServerComponent;
10

Client.tsx

1'use client';
2
3import { api } from '@convex/_generated/api';
4import { useQuery } from 'convex/react';
5
6interface Props {
7  session: {
8    userId: string;
9  };
10}
11
12export const ClientComponent = ({ session }: Props) => {
13  const user = useQuery(api.users.get, { userId: session.userId });
14
15  return <div>{user.password}</div>;
16};
17
18export default ClientComponent;
19

At first glance, this looks fine—middleware ensures the user is authenticated before the server component runs. However, authentication must still be handled explicitly in client components. If the client-side logic assumes authentication has already been verified, it could mistakenly expose data or trigger unauthorized Convex queries.

Convex is a Public API by Default.

Unlike traditional databases where queries run directly on your backend server, Convex operates as a public API, meaning queries are executed over the network. This makes it easy to call Convex functions from client components, but it also means authentication checks must happen at multiple layers—once on the client and again within Convex’s backend functions. Since your Next.js server is separate from the Convex backend, securing one doesn’t automatically secure the other.

Use Internal Functions for Increased Security

While public functions are accessible to client-side code, Convex also supports internal functions that can only be called by other functions within your Convex project. These are useful for protecting sensitive logic that shouldn't be directly accessible from the client. By using internal functions, you can minimize the public surface area of your application, reducing the risk of exposing vulnerable operations to malicious users.

Internal functions are ideal for situations where you need to run more secure, sensitive logic—such as handling authentication, processing payments, or updating user data—without risking direct client access. They can be invoked within actions, cron jobs, or other internal logic flows, ensuring that your sensitive operations are kept behind the security of your backend. However, remember that even with internal functions, you should still validate user permissions and data integrity to safeguard against potential security flaws.

Ensuring Authentication in Server Components

When using React Server Components, authentication can be enforced entirely on the server, eliminating the need for client-side checks. This approach ensures that sensitive data is never exposed to the client before authentication is verified. It also allows us to fetch user-specific data more efficiently, similar to traditional server-side rendering (SSR).

In a Next.js application, middleware can be used to enforce authentication across all server components, ensuring that only authorized users access certain pages.

Securing Routes with Middleware

Next.js middleware runs before a request reaches a page, allowing us to validate authentication and modify requests. Using Clerk, we can protect all authenticated routes by checking the user's session and redirecting them if they are not logged in.

Example from Clerk Nextjs docs

1import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
2
3const isProtectedRoute = createRouteMatcher(['/dashboard(.*)', '/forum(.*)'])
4
5export default clerkMiddleware(async (auth, req) => {
6  const { userId, redirectToSignIn } = await auth()
7
8  if (!userId && isProtectedRoute(req)) {
9    // Add custom logic to run before redirecting
10
11    return redirectToSignIn()
12  }
13})
14
15export const config = {
16  matcher: [
17    // Skip Next.js internals and all static files, unless found in search params
18    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|wo==ff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
19    // Always run for API routes
20    '/(api|trpc)(.*)',
21  ],
22}
23

With this middleware in place, any request to /dashboard or /forum will require authentication. If a user is not logged in, they will be redirected to the sign-in page automatically.

Fetching User Data in a Server Component

Once authentication is enforced through middleware, we can safely fetch user data within a server component without worrying about unauthorized access. You should still check authentication within your convex functions.

Example: Next.js Middleware for Authentication

1import { api } from "@convex/_generated/api";
2import { auth } from "@clerk/nextjs/server";
3import { fetchQuery } from "convex/nextjs";
4
5export const ServerComponent = async () => {
6  const { userId } = await auth();
7
8  const user = await fetchQuery(api.users.get, { userId });
9
10  return <div>Hello, {user.name}!</div>;
11};
12
13export default ServerComponent;
14

Why This Approach Works?

  • Middleware handles authentication upfront, ensuring that only authenticated requests reach protected pages.
  • Server components fetch data securely without exposing queries to the client.
  • No client-side authentication checks are required, reducing frontend complexity and reducing chance of race conditions.

Consider Other Frameworks and Security Risks

While this example focuses on Next.js, authentication practices vary between frameworks. If you're using Vue, Svelte, or another React meta-framework (e.g., Remix, Gatsby), you should research how authentication and data-fetching work in that ecosystem. Some frameworks may have different security risks or best practices for handling authentication, session management, and data validation. Understanding how your framework manages authentication will help you implement the most secure and efficient approach for your Convex app.

Ensuring Authentication in Client Components

While we’ve secured our server components, client-side authentication is just as important when handling user interactions and front-end data fetching. Client components in Next.js can directly interact with Convex, but we must ensure that queries are only executed when the user is authenticated. This prevents unauthorized requests and protects sensitive user data.

Convex provides the useConvexAuth() hook to manage authentication state within React components. This hook allows us to track whether the user is authenticated before making any queries or displaying protected content.

Understanding useConvexAuth()

The useConvexAuth() hook provides two key values:

  • isLoading – A boolean indicating whether the authentication state is still being determined. This helps prevent flashing unauthorized content before authentication is confirmed.
  • isAuthenticated – A boolean that indicates whether the user is signed in. If true, you can safely proceed with rendering user-specific content and making database queries.

Example: Checking Authentication in a Client Component

1'use client';
2
3import { api } from '@convex/_generated/api';
4import { useConvexAuth, useQuery } from 'convex/react';
5
6export const ClientComponent = () => {
7  const { isLoading, isAuthenticated } = useConvexAuth();
8
9  if (isLoading) return <div>Loading...</div>;
10  if (!isAuthenticated) return <div>Not allowed!</div>;
11
12  return <div>Welcome, user!</div>;
13};
14

Setting Up the Convex Authentication Provider

To use useConvexAuth(), you need to ensure that the authentication state is properly managed and passed down through a provider. In a Next.js app using Clerk, this is handled by the ConvexProviderWithClerk.

Example: Wrapping the App with an Authentication Provider

1'use client';
2
3import { ReactNode } from 'react';
4import { ConvexReactClient } from 'convex/react';
5import { ConvexProviderWithClerk } from 'convex/react-clerk';
6import { ClerkProvider, useAuth } from '@clerk/nextjs';
7
8const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL);
9
10export default function ConvexClientProvider({ children }: { children: ReactNode }) {
11  return (
12    <ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY}>
13      <ConvexProviderWithClerk client={convex} useAuth={useAuth}>
14        {children}
15      </ConvexProviderWithClerk>
16    </ClerkProvider>
17  );
18}
19

By wrapping the entire application with ConvexClientProvider, authentication data is available throughout the component tree. This ensures that the authentication state is correctly passed down to all client components.

Skipping Queries When the User is Not Authenticated

Even with proper authentication checks in the UI, we must ensure that Convex queries do not execute before the user’s authentication state is confirmed on the client side. A common mistake is triggering queries before checking authentication, which can lead to unnecessary backend calls or even security vulnerabilities.

Convex allows queries to be conditionally skipped using "skip", preventing them from running until authentication is established.

Example: Skipping Queries Until Authentication is Confirmed

1'use client';
2
3import { api } from '@convex/_generated/api';
4import { useConvexAuth, useQuery } from 'convex/react';
5
6export const ClientComponent = ({ session }: { session: any }) => {
7  const { isLoading, isAuthenticated } = useConvexAuth();
8
9  const user = useQuery(
10    api.users.get,
11    isAuthenticated ? { userId: session.userId } : "skip"
12  );
13
14  if (isLoading) return <div>Loading...</div>;
15  if (!isAuthenticated) return <div>Not allowed!</div>;
16
17  return <div>{user?.password}</div>;
18};
19

Why This Matters

  • Prevents unauthorized queries – Requests are only sent when the user is authenticated.
  • Optimizes performance – Skipping queries reduces unnecessary backend calls.
  • Avoids race conditions – Ensures authentication is fully resolved before fetching data.

Securing Your Convex Backend

While securing authentication on the client and server is crucial, the final layer of security lies in your Convex backend functions. Even if a user bypasses frontend checks, your backend must enforce strict authorization to protect your data.

Convex provides built-in authentication methods that allow you to validate user identity before processing any database operations. By correctly implementing these checks, you can ensure that only authenticated users can access or modify data.

Enforcing Authentication in Convex Functions

Every Convex function—queries, mutations, and actions—receives a context (ctx) object that contains an auth property. This allows you to verify the authenticated user before executing any logic.

Example: Restricting Access to Authenticated Users

1import { mutation } from "./_generated/server";
2import { v, ConvexError } from "convex/values"
3
4export const myMutation = mutation({
5  args: {
6    someData: v.any()
7  },
8  handler: async (ctx, { someData }) => {
9    const identity = await ctx.auth.getUserIdentity();
10
11    if (identity === null) {
12      throw new ConvexError("Unauthenticated call to mutation");
13    }
14
15    // Proceed with database operations only for authenticated users...
16  },
17});
18

**Working with User Identity Fields

When getUserIdentity() is called, it returns a UserIdentity object containing important authentication details. At a minimum, it will always include:

  • tokenIdentifier – A unique identifier for the user across authentication providers.
  • subject – The user’s unique ID from the provider.
  • issuer – The authentication provider that issued the token.

For users authenticated through Clerk or Auth0, additional fields like name, email, and pictureUrl may be available.

Enforcing Authorization with User Records

Authentication alone isn't enough—you also need to verify whether the user has permission to access certain data. A common approach is to store user records in your database and check their roles or permissions.

Example: Looking Up a User in the Database

1export async function getCurrentUserOrThrow(ctx: QueryCtx) {
2  const userRecord = await getCurrentUser(ctx);
3  if (!userRecord) throw new ConvexError("Can't get current user");
4  return userRecord;
5}
6
7export async function getCurrentUser(ctx: QueryCtx) {
8  const identity = await ctx.auth.getUserIdentity();
9  if (!identity) return null;
10  return await userByTokenIdentifier(ctx, identity.tokenIdentifier);
11}
12
13export async function userByTokenIdentifier(ctx: QueryCtx, tokenIdentifier: string) {
14  return await ctx.db
15    .query('users')
16    .withIndex('by_token', (q) => q.eq('tokenIdentifier', tokenIdentifier))
17    .unique();
18}
19

This function retrieves the authenticated user’s record from the database. If no record is found, access is denied.

Best Practices for Backend Security

  • Always validate ctx.auth.getUserIdentity() before performing database operations.
  • Restrict access by role or permissions when necessary.
  • Log authentication failures to detect unauthorized access attempts.

Considerations for Other Frameworks

If you’re using a different authentication provider or another framework like Vue or Svelte, be sure to understand how the user state is managed. Different frameworks may expose authentication details differently, and security risks can vary based on implementation. Always review your authentication provider’s documentation to ensure best practices.

Simplifying Development with Custom Hooks and Utility Functions

When working with Convex, writing safe, reusable utilities is essential for making backend logic easier to implement and reducing the risk of misusing complex functions. By creating secure wrappers around common database operations and React hooks, developers can avoid redundant code while ensuring consistent authentication and error handling.

Custom Backend Utilities: Queries, Mutations, and Actions

Convex allows you to define backend functions like queries, mutations, and actions. To ensure these operations enforce authentication consistently, we can wrap them in custom utility functions.

Example: Secure Convex Functions with Authentication

1import { action, mutation, query } from '../_generated/server';
2import {
3  customAction,
4  customCtx,
5  customMutation,
6  customQuery,
7} from 'convex-helpers/server/customFunctions';
8import { AuthenticationRequired } from '../users/utils';
9
10/** Custom query that requires authentication */
11export const authQuery = customQuery(
12  query,
13  customCtx(async (ctx) => {
14    await AuthenticationRequired({ ctx });
15    return {};
16  }),
17);
18
19/** Custom mutation that requires authentication */
20export const authMutation = customMutation(
21  mutation,
22  customCtx(async (ctx) => {
23    await AuthenticationRequired({ ctx });
24    return {};
25  }),
26);
27
28/** Custom action that requires authentication */
29export const authAction = customAction(
30  action,
31  customCtx(async (ctx) => {
32    await AuthenticationRequired({ ctx });
33    return {};
34  }),
35);
36
37/** Checks if the current user is authenticated. Throws if not */
38export async function AuthenticationRequired({
39  ctx,
40}: {
41  ctx: QueryCtx | MutationCtx | ActionCtx;
42}) {
43  const identity = await ctx.auth.getUserIdentity();
44  if (identity === null) {
45    throw new ConvexError('Not authenticated!');
46  }
47}
48

One of the major benefits of extending Convex's base functions is the ability to modify and extend the context. This allows you to inject additional data or logic before a query, mutation, or action executes. If you have further questions, I recommend joining the Convex Discord server for community support and discussions.

At a high level, a custom function wraps and calls the base function internally. This provides an opportunity to execute additional logic before the function runs. In the example above, the return {} statement passes an empty object, meaning Convex will retain its default context values. However, you can modify this to return additional data, such as a user object, which would allow all your database queries to access user data without redundant queries.

By structuring your custom functions this way, they become drop-in replacements for your existing queries and mutations while maintaining full compatibility with Convex's default behavior.

Making React Querying Easier with Custom Hooks

When working with Convex in React, fetching data can become repetitive. To improve developer experience, we can use custom hooks to abstract complex logic, making queries easier to implement while enforcing authentication.

Using useQueryWithStatus for Enhanced Query Information

The convex-helpers package provides makeUseQueryWithStatus, a utility that extends Convex’s useQuery to include additional metadata such as:

  • status: 'pending' | 'error' | 'success'
  • isPending: Whether the request is still in progress
  • isSuccess: Whether the request was successful
  • isError: Whether an error occurred
  • error: The error object
  • data: The data returned from the convex function

Example: Importing and Using useQueryWithStatus

1import { makeUseQueryWithStatus } from 'convex-helpers/react';
2import { useQueries } from 'convex-helpers/react/cache/hooks';
3
4export const useQueryWithStatus = makeUseQueryWithStatus(useQueries);
5

This utility makes working with queries more intuitive by giving immediate feedback about their status.

Authenticated Query Hooks: Simplifying Authentication Logic

Instead of manually checking if a user is authenticated before running a query in every component, we can create custom hooks that automatically handle authentication for us.

useAuthenticatedQueryWithStatus – Handling Authentication Automatically

1import { FunctionReference } from 'convex/server';
2import {
3  OptionalRestArgsOrSkip,
4  useConvexAuth,
5  useQueryWithStatus,
6} from 'convex/react';
7
8/**
9 * A wrapper around useQueryWithStatus that automatically checks authentication state.
10 * If the user is not authenticated, the query is skipped.
11 */
12export function useAuthenticatedQueryWithStatus<
13  Query extends FunctionReference<'query'>,
14>(query: Query, args: OptionalRestArgsOrSkip<Query>[0] | 'skip') {
15  const { isAuthenticated } = useConvexAuth();
16  return useQueryWithStatus(query, isAuthenticated ? args : 'skip');
17}
18

Paginated Query Hook: Handling Pagination Securely

For paginated queries, we can extend the same authentication logic:

1import {
2  PaginatedQueryArgs,
3  PaginatedQueryReference,
4  useConvexAuth,
5  usePaginatedQuery,
6} from 'convex/react';
7
8/**
9 * A wrapper around usePaginatedQuery that automatically handles authentication state.
10 * If the user is not authenticated, the query is skipped.
11 */
12export function useAuthenticatedPaginatedQuery<
13  Query extends PaginatedQueryReference,
14>(
15  query: Query,
16  args: PaginatedQueryArgs<Query> | 'skip',
17  options: { initialNumItems: number },
18) {
19  const { isAuthenticated } = useConvexAuth();
20  return usePaginatedQuery(query, isAuthenticated ? args : 'skip', options);
21}
22

Why Use Custom Hooks and Utility Functions?

Good API design and well-structured utility functions are crucial in production environments. They simplify complex logic, improve maintainability, and make debugging easier—especially when working in a team. Here’s why investing in custom hooks and utilities pays off:

  1. Encapsulate Complex Logic – Developers don’t need to understand every failure mode or edge case; they just call a function that "just works" without needing to dive into internal details.
  2. Enforce Security by Default – Authentication and permission checks happen in a centralized place, ensuring every function follows security best practices and reducing the risk of human error.
  3. Reduce Boilerplate Code – Avoid copy-pasting authentication and error-handling logic into every function. Instead, these checks happen automatically, keeping code clean and DRY.
  4. Improve Team Collaboration – A well-designed API allows other developers to use your functions without needing deep knowledge of how they work. This makes onboarding easier and speeds up development.
  5. Simplify Debugging and Logging – Wrapping key operations in utilities makes it easier to log errors consistently, trace issues, and update logic in one place without refactoring multiple files.

Flexible And Secure Development

In production, good API design isn’t just about convenience—it’s about preventing costly mistakes and improving long-term maintainability. Investing in structured, reusable utilities will save time, reduce bugs, and keep your application secure in the long run.

One Last Commit Before We Ship

At the end of the day, good API design isn’t just about making life easier—it’s about making sure you don’t wake up at 2 AM because someone forgot an authentication check. By structuring your backend utilities and React hooks properly, you’re not just writing code—you’re writing code that others will thank you for.

A well-designed API keeps things predictable, maintainable, and secure, making collaboration smoother and debugging less of a nightmare. It’s the difference between confidently shipping a feature and spending hours in a Slack thread trying to figure out why that one function call only works on Bob’s machine.

And remember: race conditions aren’t just a problem in concurrent programming. They also happen in real life—like when two developers both deploy a fix at the same time and accidentally roll back the one that actually worked.

So take your time, build smart utilities, and design APIs that make everyone’s job easier. Your future self (and your teammates) will thank you. 🚀

Build in minutes, scale forever.

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.

Get started