Day 5 — Auth and access control
The one idea. Every public Convex function is a public HTTP endpoint.
api.links.removeis not "the function my button calls" — it is a URL that anyone on the internet can POST to, with any arguments they like.
#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:
| Option | Shape | Status |
|---|---|---|
| Clerk | Hosted, "great Next.js and React Native support" | Mature. The default in most tutorials, including today's video. |
| WorkOS AuthKit | Built for B2B, free up to 1M users | Mature. Pick it if you need orgs/SSO. |
| Auth0 | "more established with more bells and whistles" | Mature, heavier. |
| Convex Auth | Runs in your own deployment, no third-party signup | Beta. 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.authcarries a claim the client cannot forge.
#Quick check 2
A logged-in user calls
deleteLinkwith another user'slinkId. Your handler checksgetUserIdentity() !== null. What happens?Answer
The link is deleted. You authenticated but never authorised. You need to load the link and confirm its
ownerIdmatches the current user before writing — and that check belongs in a sharedrequireOwnerhelper, 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.
curldoes 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
- 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.
userstable with aby_tokenindex; look the row up fromctx.auth.getUserIdentity().- Every
linksfunction scoped to the current user.ownerIdcomes fromctx.auth, never fromargs. - Extract
getCurrentUser(ctx)andrequireLinkOwner(ctx, linkId)intoconvex/model/. - 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:Before you addawait convex.mutation(api.links.remove, { linkId: "<A's id>" })requireLinkOwner, this deletes A's link. After, it throws. Do it in that order. Seeing your own app fall over is the lesson.
#Checkpoint
- Why can an argument never establish identity, no matter how well validated?
- What are the two distinct checks a protected mutation needs, and what does each answer?
- Why is a generic
update(id, patch)endpoint hard to secure? - What's the current stability status of Convex Auth, and what follows for a real project?
- 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.