All docs/general

docs/architecture/convex-clerk-jwt-authentication.md

Convex + Clerk JWT Authentication

How authentication flows from Clerk sign-in to authorized Convex queries, and why a JWT template named "convex" must exist in the Clerk dashboard.

Start Here: What Problem Are We Solving?

Convex is our data layer for organizational data only — orgs, members, roles, billing/Stripe. Videos, flows, responses, and all media stay in the existing stack (Vercel Blob, Redis, video-processor). Convex is not replacing anything; it's a new layer specifically for org/billing queries.

Clerk remains the sole authentication provider. Clerk handles sign-in, sessions, and user management — that doesn't change. Convex does not authenticate anyone. It just needs a way to verify that Clerk already authenticated the user, so it knows who's making a request.

When the browser sends a query to Convex (e.g., "load my organization"), Convex has no idea who's asking. Is it you? A hacker? A bot? It just sees an incoming network request. We need a way for the browser to prove who the user is, on every single request, without sending a password each time.

The solution: JWT tokens — think of them as tamper-proof digital ID badges that Clerk issues and Convex verifies.

The Analogy: Hotel Key Cards

Imagine three parties:

  • Clerk = the hotel front desk. Checks your passport, verifies who you are, issues you a key card.
  • Convex = a restricted floor in the hotel. You need to swipe your key card to get in.
  • JWT token = the key card itself. It has your name and room number encoded in it, and it's signed by the front desk so it can't be forged.

Here's the full flow:

  1. You check in at the front desk (Clerk). You prove who you are (email/password, Google login, etc.)
  2. The front desk gives you a key card (JWT token). Encoded on it: your name, email, and a unique ID. The front desk stamps it with a special seal (cryptographic signature) that only they can produce.
  3. You walk to the restricted floor and swipe the card (browser sends JWT to Convex).
  4. The floor reader checks the card. It doesn't call the front desk every time — it has a copy of the front desk's seal pattern (public key) and can verify the stamp is authentic. If the stamp checks out and the card hasn't expired, you're in.
  5. Inside, the staff knows who you are because your name and ID are right there on the card.

Quick read:

  • 1-2: User signs in through Clerk (the front desk).
  • 3-4: The browser asks Clerk for a key card (JWT) shaped specifically for Convex.
  • 5-6: The browser sends that key card with every Convex request. Convex checks the seal.
  • 7-8: Your server function reads the identity off the card and runs user-scoped queries.

What Is a JWT, Concretely?

A JWT (JSON Web Token) is just a long string that looks like this:

eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSJ9.kX9z...

It has three parts separated by dots:

PartWhat it containsHotel analogy
HeaderAlgorithm used for signing"This card uses magnetic stripe encoding"
Payload (claims)User data: ID, email, name, expiryThe info printed on the card: your name, room number
SignatureCryptographic proof it wasn't tampered withThe front desk's special seal/hologram

The payload for our setup looks like:

{
  "sub": "user_abc123",
  "email": "toon@happyclient.com",
  "name": "Toon Keymeulen",
  "pictureUrl": "https://...",
  "iss": "https://your-app.clerk.accounts.dev",
  "aud": "convex",
  "exp": 1710000000
}

Key claims:

  • sub (subject) — the user's unique Clerk ID. This is the most important claim. It's how Convex knows which user is making the request.
  • iss (issuer) — who created the token (your Clerk instance)
  • aud (audience) — who the token is for (Convex)
  • exp (expiration) — when the token expires

The signature makes it tamper-proof. If someone changed "sub": "user_abc123" to "sub": "user_admin" to impersonate an admin, the signature check would fail because only Clerk has the private key needed to produce a valid signature.

What Are JWT Templates?

Back to the hotel analogy. The front desk doesn't just issue one type of key card. Different facilities need different cards:

  • The gym needs a card with your name and membership level
  • The spa needs a card with your name and booking reference
  • The restricted floor (Convex) needs a card with your name, email, and avatar

A JWT template is like a card design — it defines what information goes on the card for a specific facility.

In Clerk, you create JWT templates in the dashboard. Each template has:

  1. A name — like a label on the card design (e.g., "convex", "supabase", "hasura")
  2. A set of claims — which user fields to include on the card
  3. Clerk's signature — always applied automatically

When someone asks for a card, they say which design they want: getToken({ template: "convex" }). Clerk looks up that design, fills in the current user's info, signs it, and hands it back.

The error you saw"JWT template not found" — means the front desk was asked to produce a card design called "convex", but nobody ever created that design. The front desk said: "I don't know what a 'convex' card looks like."

The Three-Way Name Contract

The name "convex" appears in three places, and they all must match. Think of it as a secret handshake where all three parties agreed on the same word:

WhereWhatWho controls it
Clerk Dashboard → JWT TemplatesThe template must be named convexYou set this up manually
ConvexProviderWithClerk (in browser)Hardcoded to call getToken({ template: "convex" })The Convex SDK does this automatically — you can't change it
auth.config.ts (on Convex server)applicationID: "convex" tells Convex to expect the aud claim to say "convex"You wrote this in code

The Convex SDK hardcodes the template name "convex". You can't change that. So the Clerk template must be named "convex", and the auth config must use applicationID: "convex".

How JWT Verification Works

When Convex receives a request with a JWT, it doesn't blindly trust it. It verifies the token is legitimate. Here's how — continuing the hotel analogy:

The key card reader doesn't call the front desk every time. Instead, it has a copy of the front desk's stamp pattern (called a "public key") and checks each card against that pattern. This is efficient — no network call needed for every request.

Where does Convex get the stamp pattern? From a publicly available URL called the JWKS endpoint (JSON Web Key Set). It's at https://your-clerk-instance/.well-known/jwks.json. Convex fetches this once (and caches it), then uses those public keys to verify every incoming JWT.

After verification, the user's identity is available in your Convex functions via ctx.auth.getUserIdentity(). No database lookup needed — the identity comes straight from the verified token.

How It Works in Our Code

Now let's map this to the actual code in this repo. There are three layers: the React provider tree, the Convex auth config, and the Convex server functions.

Layer 1: React Provider Tree (the browser)

Providers in React are like nesting dolls. Each one wraps the layer below it and provides some capability. The order matters — inner layers can access outer layers but not vice versa.

Why ClerkProvider must be on the outside: ConvexProviderWithClerk calls Clerk's useAuth() hook internally to get the JWT. If Clerk isn't above it in the tree, that hook has nothing to connect to.

What ConvexProviderWithClerk does behind the scenes:

  1. Calls useAuth() from Clerk to get the current session
  2. Calls getToken({ template: "convex" }) to get a JWT shaped for Convex
  3. Passes that token to the ConvexReactClient which attaches it to every request
  4. Refreshes the token automatically when it expires

As a developer, you never touch any of this. You just use useQuery() and the auth happens invisibly.

Layer 2: Convex Auth Config (the server)

This file lives at packages/convex/convex/auth.config.ts:

export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: "convex",
    },
  ],
};

This tells Convex: "When you receive a JWT, verify it came from this Clerk instance (domain) and that it was meant for me (applicationID: "convex")."

Note: process.env here refers to Convex's own server runtime, not Next.js. The CLERK_JWT_ISSUER_DOMAIN variable is set in the Convex dashboard, not in .env.local.

Layer 3: Using the Identity in Server Functions

Once the JWT is verified, you can read the user's identity in any Convex query, mutation, or action:

// In any Convex function:
const identity = await ctx.auth.getUserIdentity();
// identity.subject  → "user_abc123"  (the Clerk user ID)
// identity.email    → "toon@happyclient.com"
// identity.name     → "Toon Keymeulen"

Our repo wraps this in helper functions in packages/convex/convex/auth.ts:

// Require authentication — throws if not signed in
const user = await requireUser(ctx);

// Require org membership + role — throws if not authorized
const { user, membership } = await requireOrgAccess(ctx, orgId, "admin");

Authorization: From "Who Are You?" to "Can You Do This?"

Authentication (JWT verification) answers: "Who is this person?" Authorization answers: "Is this person allowed to do this?"

After the JWT tells us the user is user_abc123, we check the database to see what they can access:

The critical link: the JWT's sub claim (Clerk user ID) is stored as userClerkId in the orgMembers database table. That's how we connect "this JWT belongs to user X" to "user X is an admin of org Y."

Configuration Checklist

1. Clerk Dashboard

  1. Go to JWT TemplatesNew Template
  2. Select the Convex preset (this auto-names it "convex" and sets the right claims)
  3. Save — no customization needed

The Convex preset automatically includes these claims:

{
  "sub": "{{user.id}}",
  "email": "{{user.primary_email_address}}",
  "name": "{{user.full_name}}",
  "pictureUrl": "{{user.image_url}}"
}

2. Convex Dashboard

Set the environment variable that tells Convex where to verify JWTs:

VariableValueExample
CLERK_JWT_ISSUER_DOMAINYour Clerk instance URLhttps://your-app.clerk.accounts.dev

This is set in the Convex dashboard (Settings → Environment Variables), NOT in .env.local. Convex runs its own server — it has its own env vars.

3. Code: auth.config.ts

Already configured at packages/convex/convex/auth.config.ts. You shouldn't need to touch this.

4. Code: Next.js Apps

Each app needs:

  • NEXT_PUBLIC_CONVEX_URL in its env schema (points to your Convex deployment)
  • A ConvexClientProvider wrapping the app inside ClerkProvider

Already done for library, admin, and branded-video-flow.

Key Files in This Repo

FileRole
packages/convex/convex/auth.config.tsTells Convex how to verify Clerk JWTs
packages/convex/convex/auth.tsHelper functions: requireUser, requireOrgAccess, identity extractors
apps/*/app/_providers/convex-provider.tsxCreates ConvexReactClient + wraps with ConvexProviderWithClerk
apps/*/app/_providers/client-providers.tsxNests ConvexClientProvider inside the provider tree
apps/*/app/layout.tsxWraps everything in ClerkProvider
apps/*/lib/env.tsValidates NEXT_PUBLIC_CONVEX_URL

Common Errors

ErrorCauseFix
JWT template not foundNo JWT template named "convex" in Clerk dashboardCreate it: Clerk Dashboard → JWT Templates → Convex preset
Not authenticatedJWT missing or invalidCheck CLERK_JWT_ISSUER_DOMAIN in Convex dashboard matches your Clerk instance
NEXT_PUBLIC_CONVEX_URL validation failsEnv var not setAdd to .env.local — get URL from Convex dashboard
Convex hooks return undefinedProvider not in component treeEnsure ConvexClientProvider wraps the component

Glossary

TermPlain English
JWTA signed, tamper-proof token containing user info. Like a hotel key card.
ClaimsThe data inside a JWT (user ID, email, etc.). Like the info encoded on a key card.
JWT TemplateA Clerk feature that defines what claims go into a JWT for a specific service. Like a key card design.
JWKSA public URL where Clerk publishes its verification keys. Like the front desk publishing its stamp pattern so card readers can verify independently.
Issuer (iss)Who created the token. Must match CLERK_JWT_ISSUER_DOMAIN.
Audience (aud)Who the token is for. Must match applicationID in auth.config.ts.
Subject (sub)The user's unique ID from Clerk. This is what links a JWT to a database row.
ProviderIn React: a wrapper component that makes data/functions available to all children. In auth.config.ts: a trusted identity source (Clerk).