◆ Convex Stack Bootcamp
Day 5 of 7

Day 5 — Auth and access control

The one idea. Every public Convex function is a public HTTP endpoint. api.links.remove is not "the function my button calls" — it is a URL that anyone on the internet can POST to, with any arguments they like.

Time: ~90 min · Video: Build your own SaaS Starter Kit with Next.js, Convex & Clerk (50:42) · You ship: real auth, per-user isolation, and a documented attempt to break it.


#1. The mistake, in full

Look hard at this, because you have probably written it:

// ❌ every part of this is wrong
export const updateTeam = mutation({
  args: { id: v.id("teams"), update: v.object({ name: v.string() }) },
  handler: async (ctx, { id, update }) => {
    await ctx.db.patch("teams", id, update);
  },
});

There is no auth check at all. Any caller can rename any team. And the fix is not to add a userId argument:

// ❌ still wrong — the caller supplies the identity
args: { id: v.id("teams"), userId: v.id("users") }

Arguments come from the client. The client is a stranger holding a text field. An attacker types whichever userId they want. An argument can never authenticate anyone.

#2. The fix

export const updateTeam = mutation({
  args: { id: v.id("teams"), update: v.object({ name: v.string() }) },
  handler: async (ctx, { id, update }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (identity === null) throw new Error("Unauthorized");

    const isMember = await Teams.isMember(ctx, identity, id); // your check
    if (!isMember) throw new Error("Unauthorized");

    await ctx.db.patch("teams", id, update);
  },
});

Two checks, not one. getUserIdentity() answers "is this someone?" It does not answer "is this someone allowed to touch this row?" Authentication and authorisation are different questions and skipping the second is how you build an app where any logged-in user can delete any other user's data by changing an id in the network tab.

Convex ids are unguessable, which raises the cost of finding a target — but "hard to guess" is not an access control policy. Any id that has ever appeared in a shared URL, a screenshot, or a support ticket is public.

#3. Granular over generic

// ✅ narrow function, specific check
export const setTeamOwner = mutation({
  args: { id: v.id("teams"), owner: v.id("users") },
  handler: async (ctx, { id, owner }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (identity === null) throw new Error("Unauthorized");
    const isOwner = await Teams.isOwner(ctx, identity, id); // only the owner may transfer
    if (!isOwner) throw new Error("Unauthorized");
    await ctx.db.patch("teams", id, { owner });
  },
});

A single updateTeam(id, arbitraryPatch) cannot express "members may rename, only the owner may transfer ownership" — the permission depends on which field is being written, and a generic patch endpoint has thrown that information away. Narrow functions let the check match the operation.

#4. The model layer

Repeating the identity dance in forty handlers guarantees you eventually forget it once. Extract it:

// convex/model/users.ts — a plain function, unit-testable, no Convex wrapper
export async function getCurrentUser(ctx: QueryCtx) {
  const identity = await ctx.auth.getUserIdentity();
  if (identity === null) throw new Error("Unauthorized");
  return await Users.byToken(ctx, identity.tokenIdentifier);
}

// convex/users.ts — thin public surface
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => Users.getCurrentUser(ctx),
});

Then requireOwner(ctx, linkId) and friends. The security property becomes a function you can read, test, and grep for — instead of a habit you hope you maintained.

This is also just good architecture. Convex functions are your API boundary; business logic living in plain TypeScript behind them is what keeps a codebase workable at size.

#5. Choosing a provider

Verified from the docs, as of writing:

OptionShapeStatus
ClerkHosted, "great Next.js and React Native support"Mature. The default in most tutorials, including today's video.
WorkOS AuthKitBuilt for B2B, free up to 1M usersMature. Pick it if you need orgs/SSO.
Auth0"more established with more bells and whistles"Mature, heavier.
Convex AuthRuns in your own deployment, no third-party signupBeta. Docs: "it isn't complete and may change in backward-incompatible ways." Next.js support "under active development."

Convex Auth is the fastest way to get something working and the docs are candid about it being beta. For a learning project it's fine and instructive. For anything you intend to keep, the mature third-party options are the safer bet, and they hand you passkeys, MFA and bot protection you would otherwise be building yourself.

#6. Storing the user

Auth providers give you a stable identifier. Keep your own users row keyed to it:

users: defineTable({
  name: v.string(),
  tokenIdentifier: v.string(),
}).index("by_token", ["tokenIdentifier"]),

Indexed, because you will look a user up by token on essentially every authenticated request. This is the by_token index from the docs' own schema example — it exists in the canonical example for exactly this reason.

#Quick check 1

Why is args: { userId: v.id("users") } not authentication, even with a validator on it?

Answer

The validator checks the shape of the value, not its provenance. It confirms the caller sent a well-formed user id — including someone else's. Validation and authentication are orthogonal; only ctx.auth carries a claim the client cannot forge.

#Quick check 2

A logged-in user calls deleteLink with another user's linkId. Your handler checks getUserIdentity() !== null. What happens?

Answer

The link is deleted. You authenticated but never authorised. You need to load the link and confirm its ownerId matches the current user before writing — and that check belongs in a shared requireOwner helper, not copy-pasted per handler.

#Quick check 3

Your React app hides the delete button unless user.isAdmin. Is the endpoint protected?

Answer

No. The button is UI; the endpoint is a URL. curl does not render your React tree. Client-side checks are for ergonomics — showing people only what they can do. Server-side checks are for security. You need both, and only one of them is load-bearing.


#Build task

  1. Add auth to the link saver. Clerk is the smoothest path and matches today's video; Convex Auth is a legitimate choice if you'd rather not add a third party — just know it's beta.
  2. users table with a by_token index; look the row up from ctx.auth.getUserIdentity().
  3. Every links function scoped to the current user. ownerId comes from ctx.auth, never from args.
  4. Extract getCurrentUser(ctx) and requireLinkOwner(ctx, linkId) into convex/model/.
  5. Break it on purpose. Sign in as user A, copy a linkId. Sign in as user B, and from the browser console call the mutation directly with A's id:
    await convex.mutation(api.links.remove, { linkId: "<A's id>" })
    Before you add requireLinkOwner, this deletes A's link. After, it throws. Do it in that order. Seeing your own app fall over is the lesson.

#Checkpoint

  1. Why can an argument never establish identity, no matter how well validated?
  2. What are the two distinct checks a protected mutation needs, and what does each answer?
  3. Why is a generic update(id, patch) endpoint hard to secure?
  4. What's the current stability status of Convex Auth, and what follows for a real project?
  5. Your UI hides an action from non-admins. What's still required, and why?

#Cheat strip

identity : const id = await ctx.auth.getUserIdentity(); if (id === null) throw
NEVER    : trusting args for identity (userId, email, role) — args are attacker-controlled
ALWAYS   : authN (who?) AND authZ (may they touch THIS row?) — two checks
pattern  : convex/model/*.ts holds getCurrentUser / requireOwner; handlers stay thin
schema   : users.index("by_token", ["tokenIdentifier"])
providers: Clerk · WorkOS AuthKit · Auth0 (mature) | Convex Auth (BETA, may break)
truth    : every api.* function is a public URL. Client-side checks secure nothing.

#Tomorrow

Day 6 puts a Next.js App Router in front of this, where the interesting question is how you get server-side rendering and keep the liveness you built on Day 3.

Stored in this browser only.