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 on how you write those functions. Always validate arguments using Convex's validators (v.string(), etc.). Use the ctx.auth to check authentication. Implement authorization checks in every mutation. Remember that queries are public by default unless you add authentication checks.
Why Convex Security Matters for Vibe Coding
Convex is a backend platform that combines a reactive database with serverless functions. When AI tools generate Convex code, they often create functional queries and mutations but miss important security patterns. Convex makes it easy to build features quickly, which means security oversights can happen just as quickly.
The reactive nature of Convex means data flows directly to clients. Without proper access controls, sensitive data can be exposed through subscriptions.
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
Integrate authentication and check it in your functions:
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
Beyond authentication, implement fine-grained authorization:
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
Convex queries are public by default. Be careful about what data you expose:
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
Use internal functions for operations that should never be called from clients:
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 have argument validation with
v.*validators - String inputs have explicit length limits
- Authentication checked in all non-public functions
- Authorization verified before data access/modification
- Queries filter data based on user permissions
- Sensitive operations use internal functions
- User-specific data indexed by owner for efficient filtering
- Error messages don't leak sensitive information
- Rate-sensitive operations have throttling
- Scheduled functions validated for authorized callers
Environment Variables
// Access environment variables securely
// convex/config.ts
// These are set in the Convex dashboard
// Never hardcode secrets in code
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
Implement rate limiting for sensitive operations:
// 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. Convex queries are public and can be called by anyone with your deployment URL. You must add authentication checks in your query handlers to protect sensitive data. Always filter query results based on user permissions.
::
How do I protect internal business logic?
Use internalMutation and internalQuery for functions that should never be called from clients. These can only be called from other Convex functions, scheduled jobs, or HTTP actions.
Can users see my function code?
No, your server-side function code is not exposed to clients. However, clients can see the function names and argument schemas, so don't put secrets in function names or assume arguments are hidden.
How do I handle file uploads securely?
Use Convex's storage API with proper authentication. Generate upload URLs only for authenticated users, validate file types and sizes, and associate uploads with the authenticated user for access control.
::
What CheckYourVibe Detects
When scanning your Convex project, CheckYourVibe identifies:
- Queries without authentication checks exposing data
- Mutations without authorization verification
- Missing argument validation or length limits
- Sensitive operations not using internal functions
- Environment variables accessed incorrectly
- Missing rate limiting on abuse-prone endpoints
Run npx checkyourvibe scan to catch these issues before they reach production.