CheckYourVibe scans show roughly 40% of AI-built Convex apps have at least one public query that returns data to any caller without an authentication check. The function runs server-side, which sounds safe. But server-side only means the logic runs on Convex's infrastructure; it doesn't mean access is restricted. Any browser tab, any unauthenticated API client, any competitor's scraper can call that query and get whatever it returns.
The Convex backend is well-designed. The risks are in how AI tools generate Convex code, not in Convex itself.
TL;DR
Convex runs your queries and mutations server-side, which removes the Firebase security-rules footgun. But every exported query is publicly callable by default. You write authentication checks in TypeScript. Convex doesn't enforce them. AI tools frequently omit the getUserIdentity() call, leaving whole tables readable without a login. Add an auth check as the first line of every handler that touches non-public data.
Our Verdict
What's Good
- Server-side execution: no client-side security rules to misconfigure
- TypeScript type safety catches bad argument shapes at compile time
internalMutationandinternalQuerymake backend-only logic truly unreachable from browsers- ACID transactions with automatic conflict resolution
- Auth tokens come from verified providers (Clerk, Auth0), not forged client arguments
What to Watch
- Every exported query is public: accessible without auth by default
- No row-level security: ownership checks must be written inside each handler
- HTTP actions handle webhooks without automatic signature verification
- Scheduled functions run with no auth context
- AI-generated Convex code frequently skips the getUserIdentity() call entirely
The Core Risk: Public Queries by Default
This is what an unsafe query from a Cursor or Bolt-generated Convex app looks like:
// Any visitor can call this and get every user in your database
export const getAllUsers = query({
handler: async (ctx) => {
return await ctx.db.query("users").collect();
},
});
No login required. No token check. Every row in your users table, available to whoever calls it. This is the most common finding in our Convex scans.
The fix is one check at the top of the handler:
export const getMyProfile = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthenticated");
}
return await ctx.db
.query("users")
.filter((q) => q.eq(q.field("userId"), identity.subject))
.first();
},
});
identity.subject is the user ID from the auth provider's verified JWT. The client can't fake it. Because the filter is applied server-side, the query only ever returns data belonging to the caller.
Never accept a userId as a function argument and trust it. If your function signature includes args: { userId: v.id("users") }, any caller can pass any user's ID. Always read the userId from ctx.auth.getUserIdentity().subject. It comes from a signed token the client can't modify.
internal Functions: The Real Security Boundary
Convex's strongest security primitive is the internal prefix. Functions declared with internalQuery or internalMutation can't be called from any client, only from other server-side Convex functions:
// No browser can call this directly. Only server-side mutations can invoke it.
export const processCharge = internalMutation({
args: { userId: v.id("users"), amountCents: v.number() },
handler: async (ctx, args) => {
// Stripe charge logic here
await ctx.db.insert("charges", { userId: args.userId, amount: args.amountCents });
},
});
Any sensitive operation (payments, role changes, data deletion, admin actions) should be an internal function. AI tools almost never use this pattern without an explicit prompt telling them to.
HTTP Actions and Webhook Security
Convex HTTP actions are public HTTPS endpoints at URLs like:
https://yourproject.convex.cloud/api/webhooks/stripe
Convex doesn't verify Stripe signatures, GitHub HMAC tokens, or any other webhook-specific authentication. You write that logic yourself. Without it, anyone can POST to your webhook URL and trigger whatever the handler does.
export const stripeWebhook = httpAction(async (ctx, request) => {
const signature = request.headers.get("stripe-signature");
const body = await request.text();
// Throws ConvexError if signature is invalid
const event = stripe.webhooks.constructEvent(
body,
signature!,
process.env.STRIPE_WEBHOOK_SECRET!
);
await ctx.runMutation(internal.billing.processStripeEvent, {
type: event.type,
data: event.data.object,
});
return new Response(null, { status: 200 });
});
Scheduled Functions
Convex cron jobs and ctx.scheduler.runAfter() calls execute with no user auth context. That's expected. They're server processes. The risk appears when a scheduled function calls an exported (public) mutation instead of an internalMutation. If the mutation doesn't check auth, a client can call it directly and bypass whatever timing or rate logic the scheduler was protecting.
Use internalMutation for anything the scheduler runs that shouldn't be directly callable from a browser.
File Storage
Convex file storage returns public URLs. There's no auth layer in front of the storage URL itself. If your app stores user-uploaded documents or private photos, anyone with a direct URL can access them.
For private files: generate a short-lived access token server-side and return that to the authenticated user rather than the raw storage URL. This keeps file contents behind your auth check without exposing the underlying Convex storage endpoint.
What CheckYourVibe Finds in Convex Apps
Our scanner identifies patterns in your client-side code and deployment configuration. In AI-built Convex apps, the most common issues in order of frequency:
- Exported queries without auth checks (~40% of scanned Convex apps): the query is used in the client via
useQuery, which means the underlying function is callable without any session - userId passed as a client argument (~25%): function signatures that accept userId from the caller rather than reading it from
ctx.auth - No
internalprefix on admin mutations (~30%): admin-level operations callable from any browser console - HTTP actions without signature verification (nearly all webhook handlers): webhook URLs accepting arbitrary POST requests
The Convex backend itself has no known vulnerabilities. The risks are all in application code, specifically code that AI tools write without auth scaffolding.
Are Convex queries public by default?
Yes. Every exported Convex query is callable by any client without authentication unless you explicitly check ctx.auth.getUserIdentity() and throw on a null result. There is no equivalent of Supabase RLS. Access control lives in your TypeScript code.
Is the Convex backend safer than Firebase?
In one key way: yes. Convex functions run server-side, so there are no client-side security rules to misconfigure. But you still have to write auth checks yourself. An unprotected Convex query leaks data just as badly as an open Firebase rule.
How do I secure a Convex mutation from unauthorized writes?
Call ctx.auth.getUserIdentity() at the top of the mutation handler and throw if the result is null. Then verify the identity.subject (user ID) matches the record being modified. Never accept a userId as a function argument from the client; always read it from the verified auth token.
Does Convex have row-level security like Supabase?
No. Convex has no built-in row-level access control. You write per-row ownership checks in TypeScript inside each query and mutation. If you forget a check in one function, that function exposes every row in that table.
Can Convex HTTP actions be exploited?
Yes, if you don't validate the caller. HTTP actions are public endpoints that handle webhooks and external requests. Convex doesn't verify webhook signatures automatically. You must validate them yourself (for example, using stripe.webhooks.constructEvent for Stripe) before processing any payload.
Using Convex in Production?
Scan your app for missing auth checks, public admin mutations, and unsigned webhook handlers.