| name | security-convex |
| description | Review Convex security audit patterns for authentication and authorization. Use for auditing query/mutation auth, row-level security, and validators. Use proactively when reviewing Convex apps (convex/ directory present).
Examples:
- user: "Audit these Convex mutations" → check for missing ctx.auth and input validators
- user: "Check for IDOR in Convex queries" → verify ownership checks on document access
- user: "Review Convex HTTP actions" → check for signature verification on webhooks
- user: "Secure these Convex queries" → implement custom functions for enforced auth
- user: "Check for data leaks in subscriptions" → verify filtered result sets |
Security audit patterns for Convex applications covering authentication, authorization, input validation, and Convex-specific vulnerabilities.
The #1 Vibecoding Mistake: Unauthenticated Functions
Convex functions are public by default. Every query and mutation is callable from any client unless you add auth checks.
export const listUsers = query({
handler: async (ctx) => {
return await ctx.db.query("users").collect();
},
});
export const deleteNote = mutation({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
await ctx.db.delete(args.noteId);
},
});
export const listUsers = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return await ctx.db.query("users").collect();
},
});
Authentication Checks
Using Convex Auth
import { getAuthUserId } from "@convex-dev/auth/server";
export const getMyProfile = query({
handler: async (ctx) => {
return await ctx.db.query("users").first();
},
});
export const getMyProfile = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return await ctx.db.get(userId);
},
});
Using Third-Party Auth (Clerk, Auth0)
export const sensitiveData = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
return await ctx.db.query("secrets").collect();
},
});
export const sensitiveData = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
return await ctx.db.query("secrets")
.filter(q => q.eq(q.field("userId"), identity.subject))
.collect();
},
});
Authorization: The IDOR Problem
Authentication (who you are) ≠ Authorization (what you can access).
export const getNote = query({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return await ctx.db.get(args.noteId);
},
});
export const getNote = query({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const note = await ctx.db.get(args.noteId);
if (!note || note.userId !== userId) {
throw new Error("Not found");
}
return note;
},
});
Team/Org Membership Checks
export const getTeamData = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return await ctx.db.query("projects")
.filter(q => q.eq(q.field("teamId"), args.teamId))
.collect();
},
});
export const getTeamData = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const membership = await ctx.db.query("teamMembers")
.withIndex("by_team_user", q =>
q.eq("teamId", args.teamId).eq("userId", userId)
).first();
if (!membership) throw new Error("Not a team member");
return await ctx.db.query("projects")
.filter(q => q.eq(q.field("teamId"), args.teamId))
.collect();
},
});
Custom Functions Pattern (Recommended)
Use convex-helpers to enforce auth by default:
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { getAuthUserId } from "@convex-dev/auth/server";
export const userQuery = customQuery(query, {
args: {},
input: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const user = await ctx.db.get(userId);
if (!user) throw new Error("User not found");
return { ctx: { user }, args: {} };
},
});
export const getMyNotes = userQuery({
handler: async (ctx) => {
return await ctx.db.query("notes")
.filter(q => q.eq(q.field("userId"), ctx.user._id))
.collect();
},
});
Audit: Check if custom functions are used consistently. Search for raw query( and mutation( usage.
Input Validation
Missing Validators
export const createNote = mutation({
handler: async (ctx, args) => {
await ctx.db.insert("notes", args);
},
});
export const createNote = mutation({
args: {
title: v.string(),
content: v.string(),
isPublic: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
await ctx.db.insert("notes", {
...args,
userId,
});
},
});
Trusting Client-Provided IDs for Ownership
export const createNote = mutation({
args: {
title: v.string(),
userId: v.id("users"),
},
handler: async (ctx, args) => {
await ctx.db.insert("notes", args);
},
});
export const createNote = mutation({
args: { title: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
await ctx.db.insert("notes", {
title: args.title,
userId,
});
},
});
Internal Functions
export const processPayment = mutation({
args: { amount: v.number() },
handler: async (ctx, args) => {
await chargeCard(args.amount);
},
});
export const processPayment = internalMutation({
args: { userId: v.id("users"), amount: v.number() },
handler: async (ctx, args) => {
await chargeCard(args.amount);
},
});
export const purchaseItem = mutation({
args: { itemId: v.id("items") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const item = await ctx.db.get(args.itemId);
await ctx.runMutation(internal.payments.processPayment, {
userId,
amount: item.price,
});
},
});
HTTP Actions
export const webhook = httpAction(async (ctx, request) => {
const body = await request.json();
await ctx.runMutation(api.data.processWebhook, body);
return new Response("OK");
});
export const webhook = httpAction(async (ctx, request) => {
const signature = request.headers.get("x-webhook-signature");
const body = await request.text();
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
return new Response("Unauthorized", { status: 401 });
}
await ctx.runMutation(api.data.processWebhook, JSON.parse(body));
return new Response("OK");
});
Real-Time Subscription Leakage
export const allMessages = query({
handler: async (ctx) => {
return await ctx.db.query("messages").collect();
},
});
export const myMessages = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return [];
const myChannels = await ctx.db.query("channelMembers")
.filter(q => q.eq(q.field("userId"), userId))
.collect();
const channelIds = myChannels.map(m => m.channelId);
return await ctx.db.query("messages")
.filter(q => q.or(...channelIds.map(id =>
q.eq(q.field("channelId"), id)
)))
.collect();
},
});
Environment Variables
const apiKey = process.env.STRIPE_SECRET_KEY;
export const callStripe = action({
handler: async (ctx) => {
const apiKey = process.env.STRIPE_SECRET_KEY;
if (!apiKey) throw new Error("STRIPE_SECRET_KEY not configured");
},
});
Note: Environment variables are only available in actions, not queries/mutations.
<severity_table>
Common Vulnerabilities Summary
| Issue | Where to Look | Severity |
|---|
| No auth check in query/mutation | All query({ and mutation({ | CRITICAL |
| IDOR (no ownership check) | Functions with document IDs in args | HIGH |
| Client-provided userId | Args with v.id("users") | HIGH |
| Missing validators | Functions without args: {} | HIGH |
| Public function for internal logic | Sensitive business logic | HIGH |
| HTTP action without auth | httpAction( | HIGH |
| Subscription data leak | Queries returning collections | MEDIUM |
| Raw query/mutation (no custom fn) | Not using userQuery/userMutation | MEDIUM |
</severity_table>
Quick Audit Commands
rg "export const .* = query\(" convex/
rg "export const .* = mutation\(" convex/
rg -l "export const .* = (query|mutation)\(" convex/ | \
xargs rg -L "(getAuthUserId|getUserIdentity)"
rg 'userId: v\.id\("users"\)' convex/
rg "handler: async \(ctx\)" convex/ -g "*.ts"
rg "httpAction" convex/
rg "internalMutation|internalQuery|internalAction" convex/
Hardening Checklist