The Context
We recently shipped Permissions V2 — a Discord-style access control system for Monospace. It adds section-level ACLs with 5 permission bits (View, Create, Edit, Delete, Manage), three subject tiers (@everyone, Roles, Members), and a deny-wins resolution algorithm.
The feature was substantial: 30+ files changed, new schema tables, backend enforcement across every query and mutation, a full UI panel, and Discord bot integration. After shipping, we ran a focused security review to validate the implementation.
What we found was sobering.
The Audit
We performed a targeted code review focusing on:
- Authorization logic in the new permissions resolver
- Authentication patterns across all wrapped mutations
- Data exposure through public queries
- Bot API security for the Discord integration
The review took about 30 minutes and identified 6 vulnerabilities — 4 rated HIGH severity and 2 rated MEDIUM.
What We Found
Vuln 1: Bulk Operations Had Zero Auth (HIGH)
Four card mutations — bulkMove, bulkAssign, bulkAddLabel, and bulkDelete — had no authentication or authorization checks at all. Each contained dead code that computed a projectId variable but never used it for a permission check.
Any unauthenticated client with our deployment URL could delete, reassign, or relabel any card in the entire database.
Root cause: When we added permission checks to individual card CRUD operations, we missed the bulk variants. The scaffolding was there (the projectId lookup) but the actual requireScopePermission call was never wired up.
Vuln 2: Client-Supplied User ID Bypassed Authentication (HIGH)
This was the systemic one. Our permission check function, requireScopePermission, accepted an optional actorUserId parameter. When provided, it was used directly as the authenticated identity — completely bypassing ctx.auth.
The intent was good: Convex's ctx.auth.getUserIdentity() occasionally returns null during mutations even when the user is authenticated (a race condition with Clerk's JWT propagation). We added the actorUserId fallback so the frontend could pass user._id explicitly.
The problem: we never verified that the passed actorUserId matched the actual authenticated user. Any unauthenticated caller could pass any user ID and impersonate them for every permission-checked operation.
Impact: Every CRUD mutation in the system — cards, boards, columns, notes, links, todos, messages, tickets, calendar items — was affected.
Vuln 3: Permission Grant Management Was Spoofable (HIGH)
The accessGrants.upsert, remove, and resetScope mutations (the ones that control WHO has WHAT permissions) accepted an unverified actorUserId. An attacker who knew an admin's user ID could impersonate the admin, grant themselves full access to any project, delete other users' access grants, or wipe all permissions on a scope.
This was privilege escalation via the permissions management API itself.
Vuln 4: Migration Script Was Publicly Callable (HIGH)
backfillAllowEveryone was a one-time migration mutation intended to be run via npx convex run from the CLI. But it was exported as a regular mutation — meaning any unauthenticated client could call it at any time to grant @everyone access on every org project in the database.
Vuln 5: ACL Data Leaked Without Auth (MEDIUM)
The listForScope and getEffective queries returned full permission grant details (user IDs, role IDs, bit values) for any scope without verifying the caller's identity. This provided the reconnaissance data an attacker would need to exploit Vuln 3.
Vuln 6: Global Activity Feed Had No Auth (MEDIUM)
activityLog.listRecent returned the last 100 activity entries across ALL projects globally with zero authentication. This leaked project IDs, user IDs, action types, and target names across the entire platform.
How We Fixed Everything
All 6 fixes were deployed within an hour of discovery.
Fix 1: Added requireScopePermission with appropriate permission bits to all four bulk mutations.
Fix 2: Changed requireScopePermission to always derive the user from ctx.auth first. If both ctx.auth and actorUserId are available, it verifies they match and throws on mismatch. The fallback only kicks in when ctx.auth genuinely returns null.
Fix 3: Applied the same verification pattern to upsert, remove, and resetScope in the access grants module.
Fix 4: Changed backfillAllowEveryone from mutation (public) to internalMutation (private). Still callable via CLI, not callable from clients.
Fix 5: Both listForScope and getEffective now verify that the authenticated user matches the requested userId.
Fix 6: Added requireAdmin(ctx) to listRecent so only platform admins can access global activity.
Lessons Learned
1. Workarounds for auth reliability issues can become auth bypasses. Our actorUserId pattern was a reasonable fix for the Clerk JWT race condition, but it accidentally removed authentication from every mutation. The correct approach: verify the passed ID matches the authenticated identity.
2. Bulk operations are easy to miss during security sweeps. When we added permission checks to individual CRUD operations, the bulk variants were overlooked because they're used less frequently.
3. Migration scripts should never be public mutations. Convex's internalMutation exists exactly for this — same CLI access, zero public exposure.
4. Queries that return internal data structures need auth too. It's easy to focus auth efforts on mutations and forget that queries can leak the information attackers need to craft targeted exploits.
5. Run a security review after every significant feature. We found 6 vulnerabilities in 30 minutes. All were introduced in the same feature branch.
Timeline
- Feature shipped: April 8, 2026
- Security review: April 11, 2026
- All fixes deployed: Under 1 hour after discovery
- Total exposure window: ~3 days
No evidence of exploitation was found during the exposure window.
What This Means for Users
All vulnerabilities have been patched. No user data was compromised. The permission system is now hardened with proper authentication verification on every mutation and query.
If you're building on Convex or any backend where mutations are publicly callable, we hope this transparency helps you avoid the same patterns we fell into.