Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use custom functions for data protection - this is Convex's alternative to Row Level Security (RLS)
.claude/skills/kunanonj-cursor-plugin-convex-rule-custom-functions-for-auth/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 62% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 123% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 124% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 161% | 0% |
Convex's approach to data protection: Instead of Row Level Security (RLS) like PostgreSQL, use custom functions to wrap all queries and mutations with automatic auth and access control.
Traditional databases (PostgreSQL):
Convex approach:
Instead of writing auth checks in every function:
typescriptexport const getTasks = query({ handler: async (ctx) => { // Repeated in every function! const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); const user = await getUser(ctx, identity); return await ctx.db.query("tasks") .withIndex("by_user", q => q.eq("userId", user._id)) .collect(); }, }); export const getProjects = query({ handler: async (ctx) => { // Same auth code again! const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); const user = await getUser(ctx, identity); return await ctx.db.query("projects") .withIndex("by_user", q => q.eq("userId", user._id)) .collect(); }, });
typescript// convex/lib/authFunctions.ts import { customQuery, customMutation } from "convex-helpers/server/customFunctions"; import { query, mutation } from "../_generated/server"; import { getCurrentUser } from "./auth"; // Authenticated query - user automatically in ctx export const authedQuery = customQuery(query, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); return { ctx: { ...ctx, user }, args }; }, }); // Authenticated mutation - user automatically in ctx export const authedMutation = customMutation(mutation, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); return { ctx: { ...ctx, user }, args }; }, }); // Now use everywhere: export const getTasks = authedQuery({ handler: async (ctx) => { // ctx.user automatically available and typed! return await ctx.db.query("tasks") .withIndex("by_user", q => q.eq("userId", ctx.user._id)) .collect(); }, }); export const getProjects = authedQuery({ handler: async (ctx) => { // Same pattern, no repeated code return await ctx.db.query("projects") .withIndex("by_user", q => q.eq("userId", ctx.user._id)) .collect(); }, });
typescriptexport const authedQuery = customQuery(query, { args: {}, input: async (ctx, args) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) throw new Error("Not authenticated"); const user = await ctx.db .query("users") .withIndex("by_token", q => q.eq("tokenIdentifier", identity.tokenIdentifier) ) .unique(); if (!user) throw new Error("User not found"); return { ctx: { ...ctx, user }, args }; }, });
typescript// Admin-only functions export const adminQuery = customQuery(query, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); if (user.role !== "admin") { throw new Error("Admin access required"); } return { ctx: { ...ctx, user }, args }; }, }); // Usage export const getAllUsers = adminQuery({ handler: async (ctx) => { // Only admins can call this return await ctx.db.query("users").collect(); }, });
typescriptexport const tenantQuery = customQuery(query, { args: { organizationId: v.id("organizations") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); // Verify user belongs to this organization const membership = await ctx.db .query("organizationMembers") .withIndex("by_org_and_user", q => q.eq("organizationId", args.organizationId) .eq("userId", user._id) ) .unique(); if (!membership) { throw new Error("Not a member of this organization"); } return { ctx: { ...ctx, user, organizationId: args.organizationId, role: membership.role }, args }; }, }); // Usage - automatically scoped to organization export const getOrgProjects = tenantQuery({ args: { organizationId: v.id("organizations") }, handler: async (ctx, args) => { // Only returns projects for this org return await ctx.db .query("projects") .withIndex("by_organization", q => q.eq("organizationId", ctx.organizationId) ) .collect(); }, });
typescriptexport const ownerQuery = customQuery(query, { args: { resourceId: v.id("resources") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); const resource = await ctx.db.get(args.resourceId); if (!resource) throw new Error("Resource not found"); if (resource.ownerId !== user._id) { throw new Error("You don't own this resource"); } return { ctx: { ...ctx, user, resource }, args }; }, }); // Usage export const getResourceDetails = ownerQuery({ args: { resourceId: v.id("resources") }, handler: async (ctx, args) => { // ctx.resource already verified as owned by user return ctx.resource; }, });
typescriptexport const teamQuery = customQuery(query, { args: { teamId: v.id("teams") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); const membership = await ctx.db .query("teamMembers") .withIndex("by_team_and_user", q => q.eq("teamId", args.teamId) .eq("userId", user._id) ) .unique(); if (!membership) { throw new Error("Not a member of this team"); } return { ctx: { ...ctx, user, teamId: args.teamId, permissions: membership.permissions }, args }; }, }); // Usage export const getTeamData = teamQuery({ args: { teamId: v.id("teams") }, handler: async (ctx) => { // Automatically scoped to team // ctx.permissions available for fine-grained checks return await ctx.db .query("teamData") .withIndex("by_team", q => q.eq("teamId", ctx.teamId)) .collect(); }, });
typescript// Read operations - more permissive export const viewerQuery = customQuery(query, { args: { teamId: v.id("teams") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); const member = await ctx.db .query("teamMembers") .withIndex("by_team_and_user", q => q.eq("teamId", args.teamId).eq("userId", user._id) ) .unique(); // Any team member can read if (!member) throw new Error("Not a team member"); return { ctx: { ...ctx, user, teamId: args.teamId }, args }; }, }); // Write operations - require specific role export const editorMutation = customMutation(mutation, { args: { teamId: v.id("teams") }, input: async (ctx, args) => { const user = await getCurrentUser(ctx); const member = await ctx.db .query("teamMembers") .withIndex("by_team_and_user", q => q.eq("teamId", args.teamId).eq("userId", user._id) ) .unique(); // Must be editor or admin to write if (!member || (member.role !== "editor" && member.role !== "admin")) { throw new Error("Editor access required"); } return { ctx: { ...ctx, user, teamId: args.teamId }, args }; }, });
typescript// Public query - no auth required export const publicQuery = query; // Private query - requires auth export const privateQuery = authedQuery; // Example: Blog posts export const listPublicPosts = publicQuery({ handler: async (ctx) => { return await ctx.db .query("posts") .withIndex("by_published", q => q.eq("published", true)) .collect(); }, }); export const listMyDrafts = privateQuery({ handler: async (ctx) => { return await ctx.db .query("posts") .withIndex("by_author", q => q.eq("authorId", ctx.user._id)) .filter(q => q.eq(q.field("published"), false)) .collect(); }, });
Recommended structure:
convex/
├── lib/
│ ├── auth.ts # getCurrentUser helper
│ └── customFunctions.ts # All custom wrappers
├── users.ts # Public functions
├── tasks.ts # Use authedQuery/authedMutation
├── admin.ts # Use adminQuery/adminMutation
└── organizations.ts # Use tenantQuery/tenantMutation| Aspect | Custom Functions (Convex) | Row Level Security (PostgreSQL) | |--------|---------------------------|----------------------------------| | Language | TypeScript | SQL | | Type Safety | Full | Limited | | Complexity | Medium | High | | Flexibility | Very High | Medium | | Testing | Easy (unit tests) | Hard (DB-level) | | Debugging | Standard debugging | DB logs | | Reusability | High (compose wrappers) | Medium |
bashnpm install convex-helpers
typescript// convex/lib/customFunctions.ts import { customQuery, customMutation } from "convex-helpers/server/customFunctions"; import { query, mutation } from "../_generated/server"; import { getCurrentUser } from "./auth"; // Base: Authenticated export const authedQuery = customQuery(query, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); return { ctx: { ...ctx, user }, args }; }, }); export const authedMutation = customMutation(mutation, { args: {}, input: async (ctx, args) => { const user = await getCurrentUser(ctx); return { ctx: { ...ctx, user }, args }; }, }); // Org-scoped export const orgQuery = customQuery(authedQuery, { args: { orgId: v.id("organizations") }, input: async (ctx, args) => { const member = await ctx.db .query("members") .withIndex("by_org_and_user", q => q.eq("orgId", args.orgId).eq("userId", ctx.user._id) ) .unique(); if (!member) throw new Error("Not a member"); return { ctx: { ...ctx, orgId: args.orgId, role: member.role }, args }; }, }); export const orgMutation = customMutation(authedMutation, { args: { orgId: v.id("organizations") }, input: async (ctx, args) => { const member = await ctx.db .query("members") .withIndex("by_org_and_user", q => q.eq("orgId", args.orgId).eq("userId", ctx.user._id) ) .unique(); if (!member) throw new Error("Not a member"); return { ctx: { ...ctx, orgId: args.orgId, role: member.role }, args }; }, }); // Admin-only (within org) export const orgAdminMutation = customMutation(orgMutation, { args: { orgId: v.id("organizations") }, input: async (ctx, args) => { if (ctx.role !== "admin") { throw new Error("Admin access required"); } return { ctx, args }; }, });
npm install convex-helpersconvex/lib/customFunctions.tsauthedQuery and authedMutationquery with authedQuery in functions needing authmutation with authedMutation in functions needing auth| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | pass→pass | 10,493 | 7,329 | -30% | 1 | 1 | 0% | 2,750 | 5,585 | +103% | 0 | 0 | — |
case-02 | fail→pass | 15,527 | 12,015 | -23% | 1 | 1 | 0% | 4,155 | 6,724 | +62% | 0 | 0 | — |
case-03 | pass→pass | 9,888 | 7,100 | -28% | 1 | 1 | 0% | 2,082 | 5,223 | +151% | 0 | 0 | — |
case-04 | pass→pass | 11,840 | 6,142 | -48% | 1 | 1 | 0% | 2,446 | 5,101 | +109% | 0 | 0 | — |
case-05 | pass→pass | 11,237 | 8,614 | -23% | 1 | 1 | 0% | 2,687 | 5,688 | +112% | 0 | 0 | — |
case-06 | fail→pass | 9,899 | 6,174 | -38% | 1 | 1 | 0% | 2,298 | 5,129 | +123% | 0 | 0 | — |
case-07 | pass→pass | 14,649 | 7,657 | -48% | 1 | 1 | 0% | 3,134 | 5,467 | +74% | 0 | 0 | — |
case-08 | fail→pass | 10,942 | 7,835 | -28% | 1 | 1 | 0% | 2,487 | 5,344 | +115% | 0 | 0 | — |
case-09 | pass→pass | 13,153 | 11,348 | -14% | 1 | 1 | 0% | 3,085 | 6,276 | +103% | 0 | 0 | — |
case-10 | fail→pass | 13,190 | 11,484 | -13% | 1 | 1 | 0% | 2,737 | 6,141 | +124% | 0 | 0 | — |
case-11 | pass→pass | 3,318 | 1,700 | -49% | 1 | 1 | 0% | 571 | 3,865 | +577% | 0 | 0 | — |
case-12 | pass→pass | 11,313 | 7,461 | -34% | 1 | 1 | 0% | 2,534 | 5,160 | +104% | 0 | 0 | — |
case-13 | fail→pass | 7,765 | 4,634 | -40% | 1 | 1 | 0% | 1,757 | 4,591 | +161% | 0 | 0 | — |
case-14 | fail→pass | 7,832 | 7,139 | -9% | 1 | 1 | 0% | 1,808 | 5,298 | +193% | 0 | 0 | — |
case-15 | fail→pass | 8,143 | 6,749 | -17% | 1 | 1 | 0% | 1,929 | 5,332 | +176% | 0 | 0 | — |
case-16 | fail→pass | 23,890 | 13,099 | -45% | 1 | 1 | 0% | 5,597 | 7,185 | +28% | 0 | 0 | — |
case-17 | pass→pass | 5,591 | 3,675 | -34% | 1 | 1 | 0% | 1,103 | 4,416 | +300% | 0 | 0 | — |
case-18 | pass→pass | 12,948 | 9,286 | -28% | 1 | 1 | 0% | 2,974 | 5,686 | +91% | 0 | 0 | — |
case-19 | fail→pass | 7,491 | 5,132 | -31% | 1 | 1 | 0% | 1,470 | 4,533 | +208% | 0 | 0 | — |
case-20 | pass→pass | 11,837 | 9,135 | -23% | 1 | 1 | 0% | 2,501 | 5,631 | +125% | 0 | 0 | — |
case-21 | pass→pass | 6,507 | 5,554 | -15% | 1 | 1 | 0% | 1,475 | 4,530 | +207% | 0 | 0 | — |
case-22 | pass→pass | 4,976 | 3,975 | -20% | 1 | 1 | 0% | 1,145 | 4,442 | +288% | 0 | 0 | — |
case-23 | pass→pass | 6,729 | 6,068 | -10% | 1 | 1 | 0% | 1,536 | 4,959 | +223% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +39 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.