Convex security: 6 things to verify before flipping your app public.
auth.getUserIdentity() and reject anonymous calls." If you skip the auth check on a single function, that function is publicly readable forever — and the function name is bundled into your client code, so attackers find it in seconds. Six things to check in every Convex app: auth on every query, auth on every mutation, the deployment URL exposure, dev-mode console output, the schema's public visibility, and exposed function arguments via the bundle.
Convex is one of the cleanest BaaS designs out there — the reactive query model is genuinely a step up from polling REST APIs. The security model is also clean, but it works only if you follow it: every function enforces its own access control. There's no equivalent to Supabase's Row-Level Security. There's no built-in "anyone can read" toggle to flip off. The default is open until you write a check.
The mistake we see most: people port mental models from Supabase ("the database protects me") and assume Convex is similar. It isn't.
1. Every query function calls auth.getUserIdentity()
This is the cardinal rule. Without an auth check, the query is publicly callable.
Wrong:
// convex/messages.ts
export const list = query({
args: { conversationId: v.id("conversations") },
handler: async (ctx, { conversationId }) => {
return await ctx.db
.query("messages")
.withIndex("by_conversation", q => q.eq("conversationId", conversationId))
.collect();
},
});
Anyone with your Convex deployment URL — which is in your client bundle — can call api.messages.list({ conversationId }) for any conversation ID and read every message. The function name is auto-extracted by Convex's codegen and shipped to the browser; it's not even hidden.
Right:
export const list = query({
args: { conversationId: v.id("conversations") },
handler: async (ctx, { conversationId }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
// Verify the user is in this conversation:
const membership = await ctx.db
.query("conversation_members")
.withIndex("by_user_conversation", q =>
q.eq("userId", identity.subject).eq("conversationId", conversationId)
)
.unique();
if (!membership) throw new Error("Forbidden");
return await ctx.db
.query("messages")
.withIndex("by_conversation", q => q.eq("conversationId", conversationId))
.collect();
},
});
Test from outside:
# Probe each function from your bundle for unauthenticated access:
curl -X POST https://<your-deployment>.convex.cloud/api/query \
-H 'Content-Type: application/json' \
-d '{"path":"messages:list","args":{"conversationId":"abc123"}}'
# If it returns { "status": "success", "value": [...] } without a session,
# the function is publicly readable.
vibecheck does this probe automatically — extracts the Convex deployment URL from your bundle, finds function names, tests each one. See the fix-guide entry for the broader pattern (it's titled for Supabase but the principle applies).
2. Every mutation does the same check
Same shape, different verb. Mutations without an auth check are write-anywhere endpoints.
export const send = mutation({
args: { conversationId: v.id("conversations"), body: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
// Verify membership before writing:
const membership = await ctx.db
.query("conversation_members")
.withIndex("by_user_conversation", q =>
q.eq("userId", identity.subject).eq("conversationId", args.conversationId)
)
.unique();
if (!membership) throw new Error("Forbidden");
return await ctx.db.insert("messages", {
conversationId: args.conversationId,
authorId: identity.subject,
body: args.body,
});
},
});
Note that we set authorId from the validated identity.subject, not from a client-supplied argument. If you trust the client to send authorId, you've handed out impersonation.
3. The deployment URL is information disclosure (acceptable, but mind the implications)
Your Convex deployment URL (https://<name>.convex.cloud) is in your client bundle by design — that's how the client makes API calls. This is fine. What it implies:
- Attackers know your deployment URL.
- Attackers can probe
/versionand find your Convex client/server version. - Attackers can enumerate function names from
_generated/api.js. - Attackers will probe each function with empty args.
None of those are vulnerabilities on their own. They become vulnerabilities when paired with item #1 (a function without an auth check).
4. Dev-mode bundle in production
Convex's dev client emits more verbose logging and stack traces than the production client. If your deploy ships with NODE_ENV !== 'production' or a misconfigured Vite/Next build, the verbose client makes it into the bundle and helps attackers reverse-engineer your function logic from console output.
Test:
curl -sL https://your-app.com/ | grep -oE 'convex.*(dev|production)' | head -5
If you see convex/dev markers in production bundles, your build config is wrong. Production deploys should reference convex/browser or the production client only.
5. Schema isn't a secret, but it's a roadmap
Convex's schema.ts declares your data model. The schema isn't published to the deployment URL, but it IS bundled into your client because the codegen needs the table types. That means every table name and every field name is reachable from view-source.
This isn't a vulnerability — names alone don't grant access — but it tells attackers exactly what to target. Combined with item #1 (functions without auth), the schema is a map of which queries to probe first.
Mitigation: there's nothing to fix at the schema level. The mitigation is making sure every function rejects unauthenticated calls. Which brings us back to #1.
6. Function arguments are not validated unless you validate them
Convex's v.* validators (e.g. v.string(), v.id("table")) are required at the function-args boundary. Skipping them is rare, but when devs do it (often to prototype quickly), the function accepts arbitrary inputs:
Wrong:
export const get = query({
args: {}, // ← no validators, args is `any`
handler: async (ctx, args) => {
// attacker can send { userId: "..." } and bypass your intended logic
return await ctx.db.get(args.userId);
},
});
Right:
export const get = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
if (identity.subject !== userId) throw new Error("Forbidden");
return await ctx.db.get(userId);
},
});
The deployed-side check
vibecheck probes your Convex deployment from outside: extracts the URL from your bundle, finds function names, tests each one for unauthenticated access. If a function returns data without auth, you'll see it flagged as a finding linked to the supabase_anon_only_no_rls fix guide (the principle is identical even though the platform is different).
Inspect your Convex app