BuildBot

Beyond CRUD

Access control

Lesson 3 of 3

What you'll learn

  • Require authentication inside a function
  • Enforce ownership before reading or writing a row
  • Centralize checks so they can't be forgotten

Every Convex query, mutation, and action runs on the server, which makes it the right place — the only safe place — to enforce who can do what. Two checks cover most cases: are you signed in, and do you own this thing.

export const updatePost = mutation({
  args: { postId: v.id("posts"), body: v.string() },
  handler: async (ctx, { postId, body }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Unauthenticated");

    const post = await ctx.db.get(postId);
    if (!post) throw new Error("Not found");
    if (post.authorId !== identity.subject) throw new Error("Forbidden");

    await ctx.db.patch(postId, { body });
  },
});

Never trust an id the client sends to imply permission. Load the row, then compare its owner to the authenticated user.

Centralize the check

Repeating the auth boilerplate invites the one place you forget it. Pull it into a helper:

async function requireUser(ctx) {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new Error("Unauthenticated");
  return identity.subject;
}

The UI is not a boundary

Hiding a button stops an honest user, not a determined one. Anyone can call your functions directly, so the ownership check must live in the function — every time.

The challenge enforces ownership before an edit. Run it to see an allowed edit and a blocked one.

Enforce ownership

The edit only succeeds when the caller owns the row. Run it for both an owner and a stranger.

Loading editor…

That's the production trio: paginate large reads, push slow or external work to actions and the scheduler, and enforce access where the data lives.

Sign in to save your progress across devices.