security-convex

Security audit patterns for Convex applications covering authentication, authorization, input validation, and Convex-specific vulnerabilities.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "security-convex" with this command: npx skills add igorwarzocha/opencode-workflows/igorwarzocha-opencode-workflows-security-convex

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.

// ❌ CRITICAL: Anyone can read all users export const listUsers = query({ handler: async (ctx) => { return await ctx.db.query("users").collect(); }, });

// ❌ CRITICAL: Anyone can delete any document export const deleteNote = mutation({ args: { noteId: v.id("notes") }, handler: async (ctx, args) => { await ctx.db.delete(args.noteId); }, });

// ✓ SECURE: Check auth first export const listUsers = query({ handler: async (ctx) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Unauthenticated"); // Now safe to return data return await ctx.db.query("users").collect(); }, });

Authentication Checks

Using Convex Auth

import { getAuthUserId } from "@convex-dev/auth/server";

// ❌ No auth check export const getMyProfile = query({ handler: async (ctx) => { // Who is "my"? Anyone can call this! return await ctx.db.query("users").first(); }, });

// ✓ Get authenticated user 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)

// ❌ Just checking ctx.auth exists (wrong!) export const sensitiveData = query({ handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); // identity could be null! return await ctx.db.query("secrets").collect(); }, });

// ✓ Properly validate identity export const sensitiveData = query({ handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Unauthenticated"); // Now identity.tokenIdentifier is available 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).

// ❌ HIGH: IDOR - Any logged-in user can read any note export const getNote = query({ args: { noteId: v.id("notes") }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Unauthenticated"); // Authenticated but no ownership check! return await ctx.db.get(args.noteId); }, });

// ✓ Check ownership 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"); // Don't reveal existence
}
return note;

}, });

Team/Org Membership Checks

// ❌ Trusts client-provided teamId export const getTeamData = query({ args: { teamId: v.id("teams") }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error("Unauthenticated"); // User says they're on this team - but are they? return await ctx.db.query("projects") .filter(q => q.eq(q.field("teamId"), args.teamId)) .collect(); }, });

// ✓ Verify membership 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:

// convex/functions.ts import { customQuery, customMutation } from "convex-helpers/server/customFunctions"; import { getAuthUserId } from "@convex-dev/auth/server";

// All userQuery functions require authentication 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: {} }; }, });

// Usage - ctx.user is guaranteed to exist 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

// ❌ No validation - args is 'any' export const createNote = mutation({ handler: async (ctx, args) => { await ctx.db.insert("notes", args); // Could insert anything! }, });

// ✓ Strict validators 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, // Server sets this, not client }); }, });

Trusting Client-Provided IDs for Ownership

// ❌ Client provides userId - can impersonate anyone export const createNote = mutation({ args: { title: v.string(), userId: v.id("users"), // Client-controlled! }, handler: async (ctx, args) => { await ctx.db.insert("notes", args); }, });

// ✓ Server determines userId 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, // From auth, not args }); }, });

Internal Functions

// ❌ Sensitive logic in public mutation export const processPayment = mutation({ args: { amount: v.number() }, handler: async (ctx, args) => { // Anyone can call this! await chargeCard(args.amount); }, });

// ✓ Use internalMutation for sensitive operations export const processPayment = internalMutation({ args: { userId: v.id("users"), amount: v.number() }, handler: async (ctx, args) => { // Only callable from other server functions await chargeCard(args.amount); }, });

// Public function validates then calls internal 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);
// Validate, then call internal
await ctx.runMutation(internal.payments.processPayment, {
  userId,
  amount: item.price,
});

}, });

HTTP Actions

// ❌ No auth on HTTP endpoint export const webhook = httpAction(async (ctx, request) => { const body = await request.json(); await ctx.runMutation(api.data.processWebhook, body); return new Response("OK"); });

// ✓ Verify webhook signature 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

// ❌ Subscription returns all messages (data leak) export const allMessages = query({ handler: async (ctx) => { return await ctx.db.query("messages").collect(); }, });

// ✓ Filter by user's access export const myMessages = query({ handler: async (ctx) => { const userId = await getAuthUserId(ctx); if (!userId) return []; // Or throw

// Only messages user has access to
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

// ❌ Accessing env vars that don't exist (undefined behavior) const apiKey = process.env.STRIPE_SECRET_KEY;

// ✓ Use Convex environment variables properly // Set via: npx convex env set STRIPE_SECRET_KEY sk_live_... // Access in actions only (not queries/mutations for determinism) 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

Find all public queries and mutations

rg "export const .* = query(" convex/ rg "export const .* = mutation(" convex/

Find functions without auth checks

rg -l "export const .* = (query|mutation)(" convex/ |
xargs rg -L "(getAuthUserId|getUserIdentity)"

Find client-provided userId (potential IDOR)

rg 'userId: v.id("users")' convex/

Find functions without validators

rg "handler: async (ctx)" convex/ -g "*.ts"

Find HTTP actions

rg "httpAction" convex/

Find internal vs public function usage

rg "internalMutation|internalQuery|internalAction" convex/

Hardening Checklist

  • All queries/mutations have auth checks (or use custom functions)

  • Document access includes ownership/membership verification

  • userId comes from auth context, not client args

  • All functions have proper validators

  • Sensitive operations use internalMutation/internalAction

  • HTTP actions verify signatures/tokens

  • Real-time subscriptions filter by user access

  • Custom functions (userQuery/userMutation) used consistently

  • ESLint rules prevent importing raw query/mutation

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Security

security-nextjs

No summary provided by upstream source.

Repository SourceNeeds Review
Security

security-fastapi

No summary provided by upstream source.

Repository SourceNeeds Review
Security

security-express

No summary provided by upstream source.

Repository SourceNeeds Review