Practice Questions
1. Explain the difference between authentication and authorization. Can you have one without the other? Give a concrete example of each in a full-stack application.
Solution
Authentication answers “Who are you?” – it is about proving identity (e.g., signing in with a username/password or via OAuth). Authorization answers “Are you allowed to do this?” – it is about permissions (e.g., can this user delete this record?).
You can have authentication without authorization – a site where every signed-in user has identical abilities only needs to know who the user is, not what they are allowed to do. You cannot meaningfully have authorization without authentication – rules about “who can do what” require knowing who the caller is.
Example of authentication: a user signs in via GitHub OAuth to prove their identity. Example of authorization: after signing in, the server checks whether the user’s ID matches the ownerId field on a document before allowing deletion.
2. A junior developer argues: “We already hide the Delete button from non-owners in the React UI, so we do not need a server-side check.” Explain why this reasoning is flawed and what could go wrong.
Solution
The UI is not a security boundary. Anyone can bypass the frontend by:
- Opening the browser’s DevTools console and calling the mutation directly
- Sending a raw HTTP/WebSocket request using tools like
curlor Postman - Modifying the JavaScript in the browser to re-enable hidden buttons
If the server does not enforce the rule, a malicious user can perform any action regardless of what the UI shows or hides. UI gates are there for the user experience: they hide controls the user cannot use. Server gates are there for security: they refuse operations the user is not allowed to perform. You need both, and one does not replace the other.
3. Describe the OAuth flow at a high level. What are the roles of the three parties involved (the user’s browser, your application’s backend, and the OAuth provider), and what does each one do during the sign-in process?
Solution
The OAuth flow involves three parties:
-
Browser (client): The user clicks “Sign in with [Provider].” The browser is redirected to the OAuth provider’s authorization page.
-
OAuth provider (e.g., GitHub): Displays an authorization screen asking the user to grant permissions to the application. If the user authorizes, the provider redirects the browser back to the application’s
callback URLwith a temporary authorization code. -
Application backend: Receives the callback with the temporary code. It exchanges that code for an access token by communicating server-to-server with the provider. It then uses the access token to fetch the user’s profile information, creates or updates a user record in its own database, creates a session (often via a JWT), and redirects the browser back to the app.
Note that the user’s credentials (the password) are never shared with the application. The user authenticates directly with the provider, and the application only receives a token that proves the user authorized it.
4. Consider this authorization guard function:
async function assertDocumentOwner(
ctx: MutationCtx,
documentId: Id<"documents">,
): Promise<Doc<"documents">> {
const user = await getCurrentUser(ctx);
const doc = await ctx.db.get(documentId);
if (doc === null) {
throw new ConvexError("Document not found");
}
if (doc.ownerId !== user._id) {
throw new ConvexError("Only the owner can do this");
}
return doc;
}
(a) Why does the guard call getCurrentUser internally rather than accepting a user parameter? (b) Why does it return the document? © Write a mutation that uses this guard to allow only the owner to rename a document.
Solution
(a) Calling getCurrentUser inside the guard makes it self-contained. If the guard accepted a user parameter, every caller would have to remember to call getCurrentUser first and pass the result in. With the call inside, one call to the guard handles both authentication (“is someone signed in?”) and authorization (“is it the right person?”).
(b) The guard already loads the document from the database with ctx.db.get. Returning it lets the caller reuse the loaded document instead of reading it a second time. If the caller does not need it, the caller can ignore the return value.
©
export const rename = mutation({
args: {
id: v.id("documents"),
name: v.string(),
},
handler: async (ctx, args) => {
await assertDocumentOwner(ctx, args.id);
await ctx.db.patch(args.id, { name: args.name });
},
});
5. Write an authorization guard called assertCommentAuthorAndDeletable for a comments system with the following rule: “The comment’s author can delete their comment, but only while the comment’s status is 'draft'. Once a comment is published, it can no longer be deleted by the author.” The comments table has fields authorId: Id<"users"> and status: "draft" | "published".
Solution
async function assertCommentAuthorAndDeletable(
ctx: MutationCtx,
commentId: Id<"comments">,
): Promise<Doc<"comments">> {
const user = await getCurrentUser(ctx);
const comment = await ctx.db.get(commentId);
if (comment === null) {
throw new ConvexError("Comment not found");
}
if (comment.authorId !== user._id) {
throw new ConvexError("Only the author can delete this comment");
}
if (comment.status !== "draft") {
throw new ConvexError("This comment can no longer be deleted");
}
return comment;
}
This follows the same pattern: authenticate the caller, load the document, check identity, check state, throw with a terse error message if any check fails, and return the document for the caller to reuse.
6. What is the purpose of the <Authenticated>, <Unauthenticated>, and <AuthLoading> components? Why is <AuthLoading> necessary – what would happen without it?
Solution
These three components conditionally render their children based on the current authentication state:
<Authenticated>renders its children only when a user is signed in.<Unauthenticated>renders its children only when no user is signed in.<AuthLoading>renders its children only while the auth state is still being determined.
<AuthLoading> is necessary because on first page load, the app has not yet verified whether the user has a valid session (it needs to check the stored session token with the server). Without <AuthLoading>, every signed-in user would see a brief flash of the signed-out UI (the <Unauthenticated> branch) before the session is confirmed and the <Authenticated> branch takes over. <AuthLoading> lets you show a loading indicator during that moment instead.
7. Consider this React component that conditionally renders a button:
function ActionPanel({ item }: { item: Doc<"items"> }) {
const currentUser = useQuery(api.users.currentUser);
const isOwner = currentUser?._id === item.ownerId;
return <div>{isOwner && <DeleteButton itemId={item._id} />}</div>;
}
(a) What are the three possible values of currentUser, and what does isOwner evaluate to in each case? (b) This component is rendered inside a list that shows 100 items. Does each instance of ActionPanel create a separate subscription for currentUser? Why or why not?
Solution
(a) The three possible values of currentUser:
undefined(while the query is loading):undefined?._idevaluates toundefined, soisOwnerisfalse. The button is hidden during loading.null(user is not signed in):null?._idevaluates toundefined, soisOwnerisfalse. The button is hidden for signed-out users.- A user document (user is signed in):
currentUser._idis compared toitem.ownerId, andisOwneristrueonly if they match.
The optional chaining (?.) handles all three states in one expression.
(b) No. Convex deduplicates identical queries automatically. All 100 instances call useQuery(api.users.currentUser) with the same (empty) arguments, so the client opens only one subscription. Every component receives the same cached value. This is why calling useQuery freely inside components is fine. You pay for the subscription once, no matter how many components ask for it.
8. A blogging platform has two tables: posts (with an authorId field) and blogs (with an ownerId field). Each post belongs to a blog via a blogId field. The rule is: “Only the blog owner can publish a post (change its status from 'draft' to 'published').” Write the publishPost mutation that enforces this cross-table authorization rule.
Solution
export const publishPost = mutation({
args: {
postId: v.id("posts"),
},
handler: async (ctx, args) => {
// Step 1: Load the post to find which blog it belongs to
const post = await ctx.db.get(args.postId);
if (post === null) {
throw new ConvexError("Post not found");
}
// Step 2: Check that the current user owns the parent blog
// (assertBlogOwner handles authentication + ownership check)
await assertBlogOwner(ctx, post.blogId);
// Step 3: Perform the status change
await ctx.db.patch(args.postId, { status: "published" });
},
});
The authorization lookup spans two tables: the document being modified is the post, but the permission check depends on the blog. We have to load the post first to get the blogId, then pass that to the blog ownership guard. That means two database reads, one for the post and one inside the guard for the blog. There is no way around them, and they are cheap.
9. What is a JWT, and what role does it play in session management? Why does a JWT use a pair of keys (private and public) rather than a single shared secret?
Solution
A JWT (JSON Web Token) is a tamper-proof, signed token that proves a user’s identity. When a user signs in, the server creates a JWT containing the user’s ID and other information, signs it with a cryptographic signature, and hands it to the client. On subsequent requests, the client includes the JWT, and the server verifies the signature to confirm the token is valid and unaltered – without needing to look up session data in a database on every request.
JWTs use asymmetric cryptography (a private/public key pair) rather than a single shared secret for separation of concerns:
- The private key is used to sign (create) tokens. Only the auth server holds this key.
- The public key is used to verify tokens. Any service that needs to validate a JWT can use the public key without ever having the ability to forge new tokens.
This is more secure than a shared secret because if the verification key is exposed (e.g., to a microservice that only needs to validate tokens), an attacker still cannot create fake tokens – they would need the private key for that.
10. Why do production and development environments need separate OAuth apps and separate credentials? What would go wrong if you used the same GitHub OAuth App for both?
Solution
Each OAuth App has a registered callback URL that the provider redirects to after the user authorizes. The dev callback URL points to your development server (e.g., https://dev-slug.convex.site/api/auth/callback/github), while the production callback URL points to your production server (e.g., https://prod-slug.convex.site/api/auth/callback/github).
If you used the same OAuth App for both:
- The callback URL can only point to one deployment. If it points to dev, production sign-ins would redirect to the dev server and fail (or worse, sign the user into the wrong environment). If it points to prod, local development sign-ins would break.
- The JWT signing keys would also need to be shared, meaning a token minted in dev could be valid in production – a security risk.
With separate OAuth Apps, each environment gets its own callback URL, client credentials, and JWT key pair. That keeps the two environments isolated from each other.
11. You have a React component that renders a draggable card. The card should only be draggable by users who have permission. Using dnd-kit’s useDraggable hook, how would you disable dragging for unauthorized users? What else should you change in the UI to make the disabled state visually clear?
Solution
The useDraggable hook accepts a disabled option:
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: item._id,
disabled: !hasPermission,
});
When disabled is true, dnd-kit stops listening to pointer events, sets aria-disabled="true" for accessibility, and prevents the drag from starting.
To make the disabled state visually clear, update the cursor style so it no longer shows a grab hand for unauthorized users:
<div
ref={setNodeRef}
className={cn("text-sm", {
"cursor-grab active:cursor-grabbing": hasPermission,
})}
{...listeners}
{...attributes}
>
{item.title}
</div>
Without the cursor change, the UI would still show a grab cursor on hover, which suggests the card is draggable. The drag would not actually start, but the cursor is misleading and the user is left confused. The UI should show which actions are available before the user tries them.