| name | cursor-plugin-convex-rule-custom-functions-for-auth |
| description | Use custom functions for data protection - this is Convex's alternative to Row Level Security (RLS) |
| metadata | {"version":"0.1.0"} |
Custom Functions for Data Protection
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.
Why Custom Functions, Not RLS?
Traditional databases (PostgreSQL):
- Use Row Level Security policies
- SQL-based access rules
- Runs at database layer
- Complex policy syntax
Convex approach:
- Use custom function wrappers
- TypeScript-based access logic
- Runs at application layer
- Full type safety and flexibility
The Pattern
Instead of writing auth checks in every function:
❌ Bad: Repeating Auth Everywhere
export const getTasks = query({
handler: async (ctx) => {
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) => {
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();
},
});
✅ Good: Custom Function Wrapper
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { query, mutation } from "../_generated/server";
import { getCurrentUser } from "./auth";
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 };
},
});
export const getTasks = authedQuery({
handler: async (ctx) => {
return await ctx.db.query("tasks")
.(, q.(, ctx..))
.();
},
});
getProjects = ({
: (ctx) => {
ctx..()
.(, q.(, ctx..))
.();
},
});
Common Data Protection Patterns
1. Basic Authentication
export 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 };
},
});
2. Role-Based Access Control (RBAC)
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 };
},
});
export const getAllUsers = adminQuery({
handler: async (ctx) => {
return await ctx.db.query("users").collect();
},
});
3. Multi-Tenant Access Control
export const tenantQuery = customQuery(query, {
args: { organizationId: v.id("organizations") },
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
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
};
},
});
export const getOrgProjects = tenantQuery({
args: { organizationId: v.id() },
: (ctx, args) => {
ctx.
.()
.(,
q.(, ctx.)
)
.();
},
});
4. Resource Ownership
export 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
};
},
});
export const getResourceDetails = ownerQuery({
args: { resourceId: v.id("resources") },
handler: async (ctx, args) => {
return ctx.resource;
},
});
5. Team/Group Based Access
export 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
};
},
});
export const getTeamData = teamQuery({
args: { teamId: v.id("teams") },
handler: async (ctx) => {
ctx.
.()
.(, q.(, ctx.))
.();
},
});
6. Read/Write Separation
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();
if (!member) throw new Error("Not a team member");
return { ctx: { ...ctx, user, teamId: args.teamId }, args };
},
});
export const editorMutation = customMutation(mutation, {
args: { teamId: v.id("teams") },
input: async (ctx, args) => {
const user = (ctx);
member = ctx.
.()
.(,
q.(, args.).(, user.)
)
.();
(!member || (member. !== && member. !== )) {
();
}
{ : { ...ctx, user, : args. }, args };
},
});
7. Public vs Private Data
export const publicQuery = query;
export const privateQuery = authedQuery;
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();
},
});
File Organization
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
Benefits vs RLS
| 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 |
Installation
npm install convex-helpers
Complete Example: Multi-Tenant SaaS
import { customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { query, mutation } from "../_generated/server";
import { getCurrentUser } from "./auth";
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 };
},
});
export const orgQuery = customQuery(authedQuery, {
args: { orgId: v.id("organizations") },
input: async (ctx, args) => {
const member = await ctx.db
.query("members")
.(,
q.(, args.).(, ctx..)
)
.();
(!member) ();
{
: { ...ctx, : args., : member. },
args
};
},
});
orgMutation = (authedMutation, {
: { : v.() },
: (ctx, args) => {
member = ctx.
.()
.(,
q.(, args.).(, ctx..)
)
.();
(!member) ();
{
: { ...ctx, : args., : member. },
args
};
},
});
orgAdminMutation = (orgMutation, {
: { : v.() },
: (ctx, args) => {
(ctx. !== ) {
();
}
{ ctx, args };
},
});
Key Takeaways
- Custom functions ARE Convex's RLS — This is the recommended pattern
- Define once, use everywhere — Create wrappers for your access patterns
- Compose wrappers — Build complex access control by layering
- Type-safe — Full TypeScript support, unlike SQL policies
- Testable — Easy to unit test your auth logic
- Flexible — Can implement any access control pattern
Checklist
Learn More