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.
The edit only succeeds when the caller owns the row. Run it for both an owner and a stranger.
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.