convex-security

SKILL.md

Convex Security

Error Handling with ConvexError

Use ConvexError for user-facing errors with structured error codes:

import { ConvexError } from "convex/values";

export const updateTask = mutation({
  args: { taskId: v.id("tasks"), title: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const task = await ctx.db.get("tasks", args.taskId);

    if (!task) {
      throw new ConvexError({
        code: "NOT_FOUND",
        message: "Task not found",
      });
    }

    await ctx.db.patch("tasks", args.taskId, { title: args.title });
    return null;
  },
});

Common error codes: UNAUTHENTICATED, FORBIDDEN, NOT_FOUND, RATE_LIMITED, VALIDATION_ERROR

Argument AND Return Validators

Always define both args and returns validators:

export const createTask = mutation({
  args: {
    title: v.string(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
  },
  returns: v.id("tasks"),  // Always include returns!
  handler: async (ctx, args) => {
    return await ctx.db.insert("tasks", {
      title: args.title,
      priority: args.priority,
      completed: false,
    });
  },
});

ESLint: Use @convex-dev/require-argument-validators rule.

Authentication Helpers

Create reusable auth helpers:

// convex/lib/auth.ts
import { QueryCtx, MutationCtx } from "./_generated/server";
import { ConvexError } from "convex/values";
import { Doc } from "./_generated/dataModel";

export async function getUser(ctx: QueryCtx | MutationCtx): Promise<Doc<"users"> | null> {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) return null;

  return await ctx.db
    .query("users")
    .withIndex("by_tokenIdentifier", (q) =>
      q.eq("tokenIdentifier", identity.tokenIdentifier)
    )
    .unique();
}

export async function requireAuth(ctx: QueryCtx | MutationCtx): Promise<Doc<"users">> {
  const user = await getUser(ctx);
  if (!user) {
    throw new ConvexError({
      code: "UNAUTHENTICATED",
      message: "Authentication required",
    });
  }
  return user;
}

type UserRole = "user" | "moderator" | "admin";
const roleHierarchy: Record<UserRole, number> = { user: 0, moderator: 1, admin: 2 };

export async function requireRole(
  ctx: QueryCtx | MutationCtx,
  minRole: UserRole
): Promise<Doc<"users">> {
  const user = await requireAuth(ctx);
  const userLevel = roleHierarchy[user.role as UserRole] ?? 0;

  if (userLevel < roleHierarchy[minRole]) {
    throw new ConvexError({
      code: "FORBIDDEN",
      message: `Role '${minRole}' or higher required`,
    });
  }
  return user;
}

Usage:

export const adminOnly = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    await requireRole(ctx, "admin");
    // ... admin logic
    return null;
  },
});

Row-Level Access Control

Verify ownership before operations:

export const updateTask = mutation({
  args: { taskId: v.id("tasks"), title: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const task = await ctx.db.get("tasks", args.taskId);

    if (!task || task.userId !== user._id) {
      throw new ConvexError({ code: "FORBIDDEN", message: "Not authorized" });
    }

    await ctx.db.patch("tasks", args.taskId, { title: args.title });
    return null;
  },
});

Rate Limiting

Prevent abuse with rate limiting:

const RATE_LIMITS = {
  message: { requests: 10, windowMs: 60000 },  // 10 per minute
  upload: { requests: 5, windowMs: 300000 },   // 5 per 5 minutes
};

export const checkRateLimit = mutation({
  args: { userId: v.string(), action: v.string() },
  returns: v.object({ allowed: v.boolean(), retryAfter: v.optional(v.number()) }),
  handler: async (ctx, args) => {
    const limit = RATE_LIMITS[args.action];
    const windowStart = Date.now() - limit.windowMs;

    const requests = await ctx.db
      .query("rateLimits")
      .withIndex("by_user_and_action", (q) =>
        q.eq("userId", args.userId).eq("action", args.action)
      )
      .filter((q) => q.gt(q.field("timestamp"), windowStart))
      .collect();

    if (requests.length >= limit.requests) {
      return { allowed: false, retryAfter: requests[0].timestamp + limit.windowMs - Date.now() };
    }

    await ctx.db.insert("rateLimits", {
      userId: args.userId,
      action: args.action,
      timestamp: Date.now(),
    });
    return { allowed: true };
  },
});

Internal Functions Only for Scheduling

Always use internal.* for ctx.run* and scheduling:

// Bad - exposes public function
await ctx.scheduler.runAfter(0, api.tasks.process, { id });

// Good - uses internal function
await ctx.scheduler.runAfter(0, internal.tasks.process, { id });

// Internal function definition
export const process = internalMutation({
  args: { id: v.id("tasks") },
  returns: v.null(),
  handler: async (ctx, args) => {
    // ... processing logic
    return null;
  },
});

Tip: Never import api from _generated/api.ts in Convex functions.

Include Table Name in ctx.db

Always include table name as first argument:

// Bad
await ctx.db.get(movieId);
await ctx.db.patch(movieId, { title: "Whiplash" });

// Good
await ctx.db.get("movies", movieId);
await ctx.db.patch("movies", movieId, { title: "Whiplash" });

ESLint: Use @convex-dev/explicit-table-ids rule with autofix.

References

Weekly Installs
6
First Seen
Jan 19, 2026
Installed on
codex6
opencode5
gemini-cli5
claude-code5
amp5
github-copilot5