
Security Convex
- 77 installs
- 125 repo stars
- Updated February 4, 2026
- igorwarzocha/opencode-workflows
Audit Convex apps for missing auth, IDOR, unvalidated inputs, unsigned webhooks, and subscription data leaks.
About
A security-audit skill for Convex authentication and authorization gaps. A developer uses it to check queries/mutations for missing ctx.auth, ownership checks, and validators.
- Flags public-by-default functions lacking ctx.auth and ownership checks
- Covers IDOR, webhook signature verification, and filtered subscription results
Security Convex by the numbers
- 77 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,122 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/igorwarzocha/opencode-workflows --skill security-convexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 125 |
| Last updated | February 4, 2026 |
| Repository | igorwarzocha/opencode-workflows ↗ |
What it does
Audit Convex apps for missing auth, IDOR, unvalidated inputs, unsigned webhooks, and subscription data leaks.
Files
<overview>
Security audit patterns for Convex applications covering authentication, authorization, input validation, and Convex-specific vulnerabilities.
</overview>
<rules>
The #1 Vibecoding Mistake: Unauthenticated Functions
Convex functions are public by default. Every query and mutation is callable from any client unless you add auth checks.
// ❌ CRITICAL: Anyone can read all users
export const listUsers = query({
handler: async (ctx) => {
return await ctx.db.query("users").collect();
},
});
// ❌ CRITICAL: Anyone can delete any document
export const deleteNote = mutation({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
await ctx.db.delete(args.noteId);
},
});
// ✓ SECURE: Check auth first
export const listUsers = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
// Now safe to return data
return await ctx.db.query("users").collect();
},
});Authentication Checks
Using Convex Auth
import { getAuthUserId } from "@convex-dev/auth/server";
// ❌ No auth check
export const getMyProfile = query({
handler: async (ctx) => {
// Who is "my"? Anyone can call this!
return await ctx.db.query("users").first();
},
});
// ✓ Get authenticated user
export const getMyProfile = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
return await ctx.db.get(userId);
},
});Using Third-Party Auth (Clerk, Auth0)
// ❌ Just checking ctx.auth exists (wrong!)
export const sensitiveData = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
// identity could be null!
return await ctx.db.query("secrets").collect();
},
});
// ✓ Properly validate identity
export const sensitiveData = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
// Now identity.tokenIdentifier is available
return await ctx.db.query("secrets")
.filter(q => q.eq(q.field("userId"), identity.subject))
.collect();
},
});</rules>
<vulnerabilities>
Authorization: The IDOR Problem
Authentication (who you are) ≠ Authorization (what you can access).
// ❌ HIGH: IDOR - Any logged-in user can read any note
export const getNote = query({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
// Authenticated but no ownership check!
return await ctx.db.get(args.noteId);
},
});
// ✓ Check ownership
export const getNote = query({
args: { noteId: v.id("notes") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const note = await ctx.db.get(args.noteId);
if (!note || note.userId !== userId) {
throw new Error("Not found"); // Don't reveal existence
}
return note;
},
});Team/Org Membership Checks
// ❌ Trusts client-provided teamId
export const getTeamData = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
// User says they're on this team - but are they?
return await ctx.db.query("projects")
.filter(q => q.eq(q.field("teamId"), args.teamId))
.collect();
},
});
// ✓ Verify membership
export const getTeamData = query({
args: { teamId: v.id("teams") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const membership = await ctx.db.query("teamMembers")
.withIndex("by_team_user", q =>
q.eq("teamId", args.teamId).eq("userId", userId)
).first();
if (!membership) throw new Error("Not a team member");
return await ctx.db.query("projects")
.filter(q => q.eq(q.field("teamId"), args.teamId))
.collect();
},
});Custom Functions Pattern (Recommended)
Use convex-helpers to enforce auth by default:
// convex/functions.ts
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { getAuthUserId } from "@convex-dev/auth/server";
// All userQuery functions require authentication
export const userQuery = customQuery(query, {
args: {},
input: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const user = await ctx.db.get(userId);
if (!user) throw new Error("User not found");
return { ctx: { user }, args: {} };
},
});
// Usage - ctx.user is guaranteed to exist
export const getMyNotes = userQuery({
handler: async (ctx) => {
return await ctx.db.query("notes")
.filter(q => q.eq(q.field("userId"), ctx.user._id))
.collect();
},
});Audit: Check if custom functions are used consistently. Search for raw query( and mutation( usage.
Input Validation
Missing Validators
// ❌ No validation - args is 'any'
export const createNote = mutation({
handler: async (ctx, args) => {
await ctx.db.insert("notes", args); // Could insert anything!
},
});
// ✓ Strict validators
export const createNote = mutation({
args: {
title: v.string(),
content: v.string(),
isPublic: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
await ctx.db.insert("notes", {
...args,
userId, // Server sets this, not client
});
},
});Trusting Client-Provided IDs for Ownership
// ❌ Client provides userId - can impersonate anyone
export const createNote = mutation({
args: {
title: v.string(),
userId: v.id("users"), // Client-controlled!
},
handler: async (ctx, args) => {
await ctx.db.insert("notes", args);
},
});
// ✓ Server determines userId
export const createNote = mutation({
args: { title: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
await ctx.db.insert("notes", {
title: args.title,
userId, // From auth, not args
});
},
});Internal Functions
// ❌ Sensitive logic in public mutation
export const processPayment = mutation({
args: { amount: v.number() },
handler: async (ctx, args) => {
// Anyone can call this!
await chargeCard(args.amount);
},
});
// ✓ Use internalMutation for sensitive operations
export const processPayment = internalMutation({
args: { userId: v.id("users"), amount: v.number() },
handler: async (ctx, args) => {
// Only callable from other server functions
await chargeCard(args.amount);
},
});
// Public function validates then calls internal
export const purchaseItem = mutation({
args: { itemId: v.id("items") },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Unauthenticated");
const item = await ctx.db.get(args.itemId);
// Validate, then call internal
await ctx.runMutation(internal.payments.processPayment, {
userId,
amount: item.price,
});
},
});HTTP Actions
// ❌ No auth on HTTP endpoint
export const webhook = httpAction(async (ctx, request) => {
const body = await request.json();
await ctx.runMutation(api.data.processWebhook, body);
return new Response("OK");
});
// ✓ Verify webhook signature
export const webhook = httpAction(async (ctx, request) => {
const signature = request.headers.get("x-webhook-signature");
const body = await request.text();
if (!verifySignature(body, signature, process.env.WEBHOOK_SECRET)) {
return new Response("Unauthorized", { status: 401 });
}
await ctx.runMutation(api.data.processWebhook, JSON.parse(body));
return new Response("OK");
});Real-Time Subscription Leakage
// ❌ Subscription returns all messages (data leak)
export const allMessages = query({
handler: async (ctx) => {
return await ctx.db.query("messages").collect();
},
});
// ✓ Filter by user's access
export const myMessages = query({
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) return []; // Or throw
// Only messages user has access to
const myChannels = await ctx.db.query("channelMembers")
.filter(q => q.eq(q.field("userId"), userId))
.collect();
const channelIds = myChannels.map(m => m.channelId);
return await ctx.db.query("messages")
.filter(q => q.or(...channelIds.map(id =>
q.eq(q.field("channelId"), id)
)))
.collect();
},
});Environment Variables
// ❌ Accessing env vars that don't exist (undefined behavior)
const apiKey = process.env.STRIPE_SECRET_KEY;
// ✓ Use Convex environment variables properly
// Set via: npx convex env set STRIPE_SECRET_KEY sk_live_...
// Access in actions only (not queries/mutations for determinism)
export const callStripe = action({
handler: async (ctx) => {
const apiKey = process.env.STRIPE_SECRET_KEY;
if (!apiKey) throw new Error("STRIPE_SECRET_KEY not configured");
// ...
},
});Note: Environment variables are only available in actions, not queries/mutations.
</vulnerabilities>
<severity_table>
Common Vulnerabilities Summary
| Issue | Where to Look | Severity |
|---|---|---|
| No auth check in query/mutation | All query({ and mutation({ | CRITICAL |
| IDOR (no ownership check) | Functions with document IDs in args | HIGH |
| Client-provided userId | Args with v.id("users") | HIGH |
| Missing validators | Functions without args: {} | HIGH |
| Public function for internal logic | Sensitive business logic | HIGH |
| HTTP action without auth | httpAction( | HIGH |
| Subscription data leak | Queries returning collections | MEDIUM |
| Raw query/mutation (no custom fn) | Not using userQuery/userMutation | MEDIUM |
</severity_table>
<commands>
Quick Audit Commands
# Find all public queries and mutations
rg "export const .* = query\(" convex/
rg "export const .* = mutation\(" convex/
# Find functions without auth checks
rg -l "export const .* = (query|mutation)\(" convex/ | \
xargs rg -L "(getAuthUserId|getUserIdentity)"
# Find client-provided userId (potential IDOR)
rg 'userId: v\.id\("users"\)' convex/
# Find functions without validators
rg "handler: async \(ctx\)" convex/ -g "*.ts"
# Find HTTP actions
rg "httpAction" convex/
# Find internal vs public function usage
rg "internalMutation|internalQuery|internalAction" convex/</commands>
<checklist>
Hardening Checklist
- [ ] All queries/mutations have auth checks (or use custom functions)
- [ ] Document access includes ownership/membership verification
- [ ] userId comes from auth context, not client args
- [ ] All functions have proper validators
- [ ] Sensitive operations use internalMutation/internalAction
- [ ] HTTP actions verify signatures/tokens
- [ ] Real-time subscriptions filter by user access
- [ ] Custom functions (userQuery/userMutation) used consistently
- [ ] ESLint rules prevent importing raw query/mutation
</checklist>
#!/usr/bin/env bash
# Convex Security Scanner - First-pass automated detection
# Usage: ./scan.sh [directory]
set -euo pipefail
DIR="${1:-.}"
CONVEX_DIR="$DIR/convex"
FOUND=0
echo "=== CONVEX SECURITY SCAN ==="
echo "Directory: $DIR"
echo "Timestamp: $(date -Iseconds)"
echo ""
if [[ ! -d "$CONVEX_DIR" ]]; then
echo "[INFO] No convex/ directory found. Skipping Convex-specific checks."
exit 0
fi
if ! command -v rg &> /dev/null; then
echo "[ERROR] ripgrep (rg) required. Install with: brew install ripgrep"
exit 1
fi
report() {
local severity="$1"
local title="$2"
local file="$3"
local line="$4"
echo "[$severity] $title"
echo " File: $file:$line"
echo ""
FOUND=$((FOUND + 1))
}
echo "=== CRITICAL: Unauthenticated Public Functions ==="
echo ""
# Find all query/mutation exports
QUERY_FILES=$(rg -l 'export const .* = (query|mutation)\(' "$CONVEX_DIR" -g "*.ts" 2>/dev/null || true)
for file in $QUERY_FILES; do
[[ -z "$file" ]] && continue
# Check if file has auth imports/calls
if ! rg -q '(getAuthUserId|getUserIdentity|ctx\.auth)' "$file" 2>/dev/null; then
# Get the function names
FUNCS=$(rg -n 'export const (\w+) = (query|mutation)\(' "$file" -o -r '$1' 2>/dev/null || true)
if [[ -n "$FUNCS" ]]; then
echo "[CRITICAL] File has public functions without auth checks:"
echo " File: $file"
echo " Functions: $(echo $FUNCS | tr '\n' ', ')"
echo ""
FOUND=$((FOUND + 1))
fi
fi
done
echo "=== HIGH: Client-Provided userId (IDOR Risk) ==="
echo ""
# userId in args
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "HIGH" "userId in function args (should come from auth context)" "$file" "$line"
done < <(rg -n --no-heading 'userId:\s*v\.id\(["'\''](users)["'\''\)]' "$CONVEX_DIR" -g "*.ts" 2>/dev/null || true)
echo "=== HIGH: Missing Validators ==="
echo ""
# Functions without args object
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "HIGH" "Function handler without args validation" "$file" "$line"
done < <(rg -n --no-heading 'handler:\s*async\s*\(ctx\)\s*=>' "$CONVEX_DIR" -g "*.ts" 2>/dev/null || true)
echo "=== MEDIUM: HTTP Actions ==="
echo ""
# httpAction usage
while IFS=: read -r file line match; do
[[ -z "$file" ]] && continue
report "MEDIUM" "HTTP action found (verify authentication)" "$file" "$line"
done < <(rg -n --no-heading 'httpAction' "$CONVEX_DIR" -g "*.ts" 2>/dev/null || true)
echo "=== INFO: Internal vs Public Functions ==="
echo ""
# Count internal vs public
PUBLIC_COUNT=$(rg -c 'export const .* = (query|mutation|action)\(' "$CONVEX_DIR" -g "*.ts" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
INTERNAL_COUNT=$(rg -c 'export const .* = internal(Query|Mutation|Action)\(' "$CONVEX_DIR" -g "*.ts" 2>/dev/null | awk -F: '{sum+=$2} END {print sum+0}')
echo "Public functions: $PUBLIC_COUNT"
echo "Internal functions: $INTERNAL_COUNT"
echo ""
if [[ $PUBLIC_COUNT -gt 0 && $INTERNAL_COUNT -eq 0 ]]; then
echo "[WARN] No internal functions found. Consider using internalMutation/internalAction for sensitive operations."
FOUND=$((FOUND + 1))
fi
echo "=== SUMMARY ==="
if [[ $FOUND -gt 0 ]]; then
echo "[!] Found $FOUND potential issues. Review above."
exit 1
else
echo "[✓] No obvious Convex security issues detected"
exit 0
fi