Convex Security Guide for Vibe Coders

Convex Security Guide for Vibe Coders

Published on January 23, 2026 - 11 min read

TL;DR

Convex provides a reactive database with built-in functions, but security depends entirely on how you write those functions. Always validate arguments using Convex's validators (v.string(), etc.) and add explicit length limits (the type system won't do that for you). Use ctx.auth to check authentication, and add authorization checks in every mutation. Queries are public by default.

Why Convex Security Matters for Vibe Coding

Convex combines a reactive database with serverless functions in a way that makes features very fast to build. That speed cuts both ways. AI tools generating Convex code tend to get the happy path right (the query returns data, the mutation inserts it), but they skip the authorization checks that keep one user from reading another's rows.

The reactive part is worth paying attention to: data flows directly to subscribed clients. If a query isn't filtered by user, anyone who subscribes gets everything.

Argument Validation

Convex requires explicit argument validation using its validator system:

Basic Validation

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";

// SECURE: Arguments validated
export const getDocument = query({
  args: {
    documentId: v.id("documents"),
  },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.documentId);
  },
});

// SECURE: Complex validation
export const createDocument = mutation({
  args: {
    title: v.string(),
    content: v.string(),
    isPublic: v.optional(v.boolean()),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    // Validate string lengths
    if (args.title.length > 200) {
      throw new Error("Title too long");
    }
    if (args.content.length > 50000) {
      throw new Error("Content too long");
    }

    return await ctx.db.insert("documents", {
      title: args.title,
      content: args.content,
      isPublic: args.isPublic ?? false,
      tags: args.tags ?? [],
      createdAt: Date.now(),
    });
  },
});

Convex Validators Don't Check Length

The v.string() validator confirms the type but doesn't limit length. Always add explicit length checks for strings that will be stored or displayed to prevent abuse.

Authentication

Authentication in Convex isn't automatic. You check it yourself in each function:

Setting Up Auth Context

// convex/auth.ts
import { query, mutation, QueryCtx, MutationCtx } from "./_generated/server";

// Helper to get authenticated user
export async function getUser(ctx: QueryCtx | MutationCtx) {
  const identity = await ctx.auth.getUserIdentity();

  if (!identity) {
    return null;
  }

  // Get or create user in database
  const user = await ctx.db
    .query("users")
    .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
    .unique();

  return user;
}

// Helper that throws if not authenticated
export async function requireAuth(ctx: QueryCtx | MutationCtx) {
  const user = await getUser(ctx);

  if (!user) {
    throw new Error("Authentication required");
  }

  return user;
}

Protected Functions

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { requireAuth, getUser } from "./auth";

// Protected query - requires authentication
export const getMyDocuments = query({
  args: {},
  handler: async (ctx) => {
    const user = await requireAuth(ctx);

    return await ctx.db
      .query("documents")
      .withIndex("by_owner", (q) => q.eq("ownerId", user._id))
      .collect();
  },
});

// Protected mutation
export const deleteDocument = mutation({
  args: {
    documentId: v.id("documents"),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    const document = await ctx.db.get(args.documentId);

    if (!document) {
      throw new Error("Document not found");
    }

    // Authorization check
    if (document.ownerId !== user._id) {
      throw new Error("Not authorized to delete this document");
    }

    await ctx.db.delete(args.documentId);
  },
});

Authorization Patterns

Authentication tells you who's calling. Authorization tells you what they're allowed to do. You need both:

Role-Based Access

// Check user role
async function requireRole(ctx: MutationCtx, role: "admin" | "moderator") {
  const user = await requireAuth(ctx);

  if (!user.roles?.includes(role)) {
    throw new Error(`${role} access required`);
  }

  return user;
}

// Admin-only mutation
export const deleteAnyDocument = mutation({
  args: {
    documentId: v.id("documents"),
  },
  handler: async (ctx, args) => {
    await requireRole(ctx, "admin");

    const document = await ctx.db.get(args.documentId);
    if (!document) {
      throw new Error("Document not found");
    }

    await ctx.db.delete(args.documentId);
  },
});

Resource-Based Access

// Check if user can access a workspace
async function canAccessWorkspace(
  ctx: QueryCtx | MutationCtx,
  workspaceId: Id<"workspaces">
) {
  const user = await requireAuth(ctx);

  const membership = await ctx.db
    .query("workspaceMembers")
    .withIndex("by_user_workspace", (q) =>
      q.eq("userId", user._id).eq("workspaceId", workspaceId)
    )
    .unique();

  return membership !== null;
}

// Workspace-scoped query
export const getWorkspaceDocuments = query({
  args: {
    workspaceId: v.id("workspaces"),
  },
  handler: async (ctx, args) => {
    if (!await canAccessWorkspace(ctx, args.workspaceId)) {
      throw new Error("Not a member of this workspace");
    }

    return await ctx.db
      .query("documents")
      .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
      .collect();
  },
});

Query Visibility

Every Convex query is callable by anyone with your deployment URL unless you add a check. Here's what that looks like when it goes wrong:

Dangerous: Exposing All Data

// DANGEROUS: Anyone can query all documents
export const getAllDocuments = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db.query("documents").collect();
  },
});
// SECURE: Only return public documents or user's own
export const getDocuments = query({
  args: {},
  handler: async (ctx) => {
    const user = await getUser(ctx);

    // Build query based on auth state
    const documents = await ctx.db.query("documents").collect();

    // Filter to public docs or user's own
    return documents.filter((doc) =>
      doc.isPublic || (user && doc.ownerId === user._id)
    );
  },
});

// Better: Use an index for efficiency
export const getAccessibleDocuments = query({
  args: {},
  handler: async (ctx) => {
    const user = await getUser(ctx);

    // Get public documents
    const publicDocs = await ctx.db
      .query("documents")
      .withIndex("by_public", (q) => q.eq("isPublic", true))
      .collect();

    if (!user) {
      return publicDocs;
    }

    // Get user's own documents
    const userDocs = await ctx.db
      .query("documents")
      .withIndex("by_owner", (q) => q.eq("ownerId", user._id))
      .filter((q) => q.eq(q.field("isPublic"), false))
      .collect();

    return [...publicDocs, ...userDocs];
  },
});

Internal Functions

Some operations should never reach a client at all. Convex's internalMutation and internalQuery enforce that at the platform level:

import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";

// Internal function - not callable from client
export const processPayment = internalMutation({
  args: {
    userId: v.id("users"),
    amount: v.number(),
  },
  handler: async (ctx, args) => {
    // This can only be called from other Convex functions
    // Not from the client SDK
    await ctx.db.insert("payments", {
      userId: args.userId,
      amount: args.amount,
      processedAt: Date.now(),
    });
  },
});

// Public mutation that uses internal function
export const subscribe = mutation({
  args: {
    planId: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    // ... validate plan and calculate amount ...

    // Call internal function
    await ctx.scheduler.runAfter(0, internal.payments.processPayment, {
      userId: user._id,
      amount: planAmount,
    });
  },
});

Convex Security Checklist

  • All functions validate arguments with v.* validators
  • String inputs have explicit length limits (the type check doesn't do this)
  • Authentication checked in every non-public function
  • Authorization verified before each data access or write
  • Queries return only data the caller is allowed to see
  • Payment and admin operations use internalMutation / internalQuery
  • Error messages don't leak internal details
  • Rate-sensitive endpoints have throttling

Environment Variables

// convex/config.ts
// Set these in the Convex dashboard. Never hardcode secrets.

export const getStripeKey = () => {
  const key = process.env.STRIPE_SECRET_KEY;
  if (!key) {
    throw new Error("STRIPE_SECRET_KEY not configured");
  }
  return key;
};

// Usage in mutation
export const createCheckout = mutation({
  args: { priceId: v.string() },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);
    const stripe = new Stripe(getStripeKey());

    // Create checkout session...
  },
});

Rate Limiting

Convex doesn't have built-in rate limiting, but you can implement it with a quick database query:

// Simple rate limiting using database
export const sendMessage = mutation({
  args: {
    channelId: v.id("channels"),
    content: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await requireAuth(ctx);

    // Check rate limit
    const recentMessages = await ctx.db
      .query("messages")
      .withIndex("by_author_time", (q) =>
        q.eq("authorId", user._id).gt("createdAt", Date.now() - 60000)
      )
      .collect();

    if (recentMessages.length >= 30) {
      throw new Error("Rate limit exceeded. Max 30 messages per minute.");
    }

    // Validate content length
    if (args.content.length > 2000) {
      throw new Error("Message too long");
    }

    return await ctx.db.insert("messages", {
      channelId: args.channelId,
      authorId: user._id,
      content: args.content,
      createdAt: Date.now(),
    });
  },
});

Are Convex queries secure by default?

No. Queries are public, and anyone with your deployment URL can call them. You need to add authentication checks yourself in each handler. If you're not filtering results by user, every caller sees everything.

How do I protect internal business logic?

Use internalMutation and internalQuery. These can't be called from the client SDK: only from other Convex functions, scheduled jobs, or HTTP actions. Payment processing and admin operations belong there.

Can users see my function code?

No, the function code isn't exposed. But clients can see function names and argument schemas, so don't embed secrets in names or assume your arg structure is hidden.

How do I handle file uploads securely?

Use Convex's storage API with authentication. Generate upload URLs only for authenticated users, validate file types and sizes server-side, and tie the upload to the user's ID so access control works.

What CheckYourVibe Detects

When scanning a Convex project, CheckYourVibe flags:

  • Queries that return data without any authentication check
  • Mutations that don't verify the caller owns what they're modifying
  • Argument validation gaps (string fields with no length limit)
  • Payment or admin operations that should be internal but aren't
  • Environment variable access patterns that suggest hardcoded fallbacks

Run npx checkyourvibe scan to catch these before they ship.

Tool & Platform Guides

Convex Security Guide for Vibe Coders