Features
Security
Overview
Vextro's security is enforced entirely at the Convex function boundary. There is no network-level protection below it. Every query and mutation in the admin module goes through two gates:
- Authentication (
requireAdminRead/requireAdminWrite) -- verify the caller is who they claim to be - Authorization (
checkCollectionAccess/checkGlobalAccess) -- verify the caller has the right role for the requested operation
If either gate is misconfigured, the admin panel is exposed. This guide covers every configuration point.
Auth Architecture
CLIENT → (JWT) → Convex ctx.auth.getUserIdentity()
↓
requireAdminRead / requireAdminWrite
↓
resolveUserRoles (getUserRoles from module config)
↓
checkCollectionAccess / checkGlobalAccess
↓
fieldPermissions (per-field read/write roles) Better Auth path: session cookie → getToken(convexSiteUrl) → Convex JWT
Clerk path: __session cookie → Clerk JWT (aud=convex) → Convex JWT
Both paths set locals.convexAuthToken in Astro middleware. convex/auth.config.ts declares trusted JWT issuers.
Auth Guards
requireAdminRead / requireAdminWrite
These are the first line of defense. Every query calls requireAdminRead; every mutation calls requireAdminWrite. The default implementation only checks that a JWT identity exists:
// Default (identity-only) — sufficient for prototyping, NOT for production
const defaultRequireAuth = async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
}; For production, override these to check admin roles:
const admin = createVextroAdminModule({
query,
mutation,
components,
requireAdminRead: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Example: check a custom claim from your auth provider
const claims = identity.customClaims as Record<string, unknown> | undefined;
if (!claims?.isAdmin) throw new Error("Admin access required");
},
requireAdminWrite: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const claims = identity.customClaims as Record<string, unknown> | undefined;
if (!claims?.isAdmin) throw new Error("Admin access required");
},
}); Template Stubs
The scaffolded convex/admin.ts template includes stub guards that throw by default:
async function requireAdminRead(ctx) {
throw new Error("[vextro] requireAdminRead is not implemented...");
} You must replace these with real auth checks before deploying.
RBAC Configuration
getUserRoles
Collection-level RBAC depends on getUserRoles. If you don't provide it, all collection access rules are silently skipped.
const admin = createVextroAdminModule({
// ...
getUserRoles: async (ctx) => {
const user = await getCurrentUser(ctx);
if (!user) return [];
return await getUserRoleSlugs(ctx, user._id);
},
}); rbacFailOpen
Controls what happens when getUserRoles throws an error:
false(default): re-throws the error, denying access (fail-closed)true: returns null, bypassing RBAC checks (fail-open)
const admin = createVextroAdminModule({
// ...
rbacFailOpen: false, // Default — always fail closed in production
}); Never set rbacFailOpen: true in production. A transient database error in your role lookup would silently grant full access.
Collection Access
Define per-collection role requirements:
const posts = defineCollection({
slug: "posts",
tableName: "posts",
access: {
read: "editor", // Only "editor" role can read
create: "editor",
update: "editor",
delete: "admin", // Only "admin" role can delete
},
}); When getUserRoles returns ["editor"], the user can read/create/update posts but cannot delete them.
Server-Side Mutation Validation
All document-write mutations now run server-side field validation before writing to the database. This applies to create, update, duplicate, and global-document update operations.
What is validated
- Unknown field stripping — fields not present in the collection schema are removed before the write
- readOnly field stripping — fields marked
readOnly: trueare removed server-side regardless of what the client sends - Type sanitization — values are normalized to the expected type (e.g. numeric strings coerced to numbers for number fields)
- Constraint validation — the following constraints are enforced:
required— field must be present and non-emptyminLength/maxLength— string length boundsmaxCharacters— character count limit (for rich-text fields)min/max— numeric boundspattern— regex match, with ReDoS protection (overly complex patterns are rejected)email— valid email address formaturl— valid URL formatcolor— valid CSS hex colorpoint— latitude/longitude bounds (lat∈ [-90, 90],lng∈ [-180, 180])
Validation error format
When validation fails the mutation throws a ConvexError. The error.data shape is:
// error.data shape:
{
code: "VALIDATION_FAILED",
message: "Validation failed with 2 error(s)",
errors: [
{ field: "title", message: "This field is required." },
{ field: "price", message: "Maximum value is 100." },
]
} The admin UI surfaces these errors inline next to their respective fields. If you call mutations directly, catch ConvexError and inspect error.data.errors for structured per-field messages.
Security Utilities
Import from vextro/convex/security:
import {
createStrictRbacGuard,
assertCollectionAccess,
createWebhookSecretGuard,
validateDocumentPayload,
} from "vextro/convex/security"; validateDocumentPayload
Validates and sanitizes mutation payloads against field definitions. Strips system fields (_id, _creationTime) and fields not in the schema:
validateDocumentPayload(args.data, fields);
// args.data is now asserted as Record<string, unknown>
// with only known fields remaining This function is also called automatically by all document-write mutations. You only need to call it directly in custom mutations that bypass the admin module.
Deprecated helpers
The following helpers are deprecated and will be removed in a future release. Prefer the admin module's built-in equivalents.
| Deprecated | Replacement |
|---|---|
createStrictRbacGuard() | Admin module's built-in role resolution via getUserRoles |
assertCollectionAccess() | Admin module's checkCollectionAccess |
createWebhookSecretGuard() | Will be removed — restrict webhook routes with requireAdminRead instead |
The deprecated helpers remain importable from vextro/convex/security for now but will be removed in the next major release.
createStrictRbacGuard (deprecated)
Creates a reusable guard that resolves user roles with fail-closed semantics:
const resolveRoles = createStrictRbacGuard({
getUserRoles,
failOpen: false,
});
// In a query handler:
const userRoles = await resolveRoles(ctx); assertCollectionAccess (deprecated)
Throws a descriptive error if the user lacks the required role:
assertCollectionAccess({
collectionSlug: "posts",
operation: "update",
userRoles,
accessMap,
}); createWebhookSecretGuard (deprecated)
Restricts webhook secret access to users with a specific role:
const guardWebhookSecret = createWebhookSecretGuard({
requiredRole: "admin",
});
// In a query handler:
guardWebhookSecret(userRoles); Upload Security
MIME Type Allowlist
The upload endpoint validates MIME types against a configurable allowlist before issuing presigned URLs or storing files. The default list includes common image, document, video, and audio types.
const endpoints = createUploadEndpoints({
convex,
api,
// Override the default allowlist
allowedMimeTypes: [
"image/jpeg",
"image/png",
"image/webp",
"application/pdf",
],
// Override the default max file size (50 MB)
maxFileSizeBytes: 10 * 1024 * 1024, // 10 MB
}); Requests with unlisted MIME types or files exceeding the size limit receive a 400 error before any storage operation occurs.
Auth on Upload Routes
The upload endpoints themselves do not include built-in authentication. You must protect them with Astro middleware:
// src/pages/api/vextro/upload/[...action].ts
export const POST = async (context) => {
// Verify auth before handling upload
if (!context.locals.convexAuthToken) {
return new Response("Unauthorized", { status: 401 });
}
return endpoints.handleRequest(context);
}; The same pattern applies regardless of your auth provider. Because createClerkMiddleware and createAuthMiddleware (Better Auth) both write the session token into context.locals.convexAuthToken, the upload handler above works unchanged with either.
If you are using Clerk and need the middleware to protect upload API routes explicitly, ensure your isApiRoute predicate includes the upload path:
// src/middleware.ts
import { createClerkMiddleware } from "vextro/auth/clerk/middleware";
export const onRequest = createClerkMiddleware({
secretKey: import.meta.env.CLERK_SECRET_KEY,
isAuthRoute: (pathname) => pathname === "/login",
// Upload routes begin with /api/ and are protected by default.
// If you have customised isApiRoute, make sure upload paths are included:
isApiRoute: (pathname) =>
pathname.startsWith("/api/") && !pathname.startsWith("/api/auth/"),
}); With this in place, any unauthenticated request to /api/vextro/upload/* receives a 401 Unauthorized JSON response before reaching your page handler.
JWT Configuration
convex/auth.config.ts
Lock the issuer and audience to your exact auth provider domain:
// convex/auth.config.ts
export default {
providers: [
{
domain: "https://your-app.clerk.accounts.dev",
applicationID: "convex",
},
],
}; Never use wildcard domains. An overly broad issuer configuration could accept JWTs from other tenants or services.
Production Deployment Checklist
Before deploying to production, verify:
- [ ]
requireAdminReadandrequireAdminWritecheck admin role claims, not just identity - [ ]
getUserRolesis wired and returns role slugs from your RBAC tables - [ ]
rbacFailOpenisfalse(the default) - [ ]
convex/auth.config.tsissuer domain matches your auth provider exactly - [ ] Upload endpoints are protected by auth middleware
- [ ] Upload
allowedMimeTypesis restricted to types your app actually needs - [ ] Upload
maxFileSizeBytesis set to a reasonable limit - [ ] Template stubs in
convex/admin.tshave been replaced with real implementations - [ ] Component functions (
packages/vextro/convex/) are not directly exposed to clients - [ ] Webhook routes are protected with
requireAdminRead(notcreateWebhookSecretGuard, which is deprecated) - [ ] Required fields, length constraints, and format validations are declared in field definitions so server-side validation enforces them automatically
Rate Limiting
Convex does not provide built-in rate limiting. For destructive or expensive mutations, use the convex-helpers rate limiter:
import { RateLimiter } from "convex-helpers/server/rateLimit";
const rateLimiter = new RateLimiter(components.rateLimiter, {
deleteDocument: { kind: "token bucket", rate: 10, period: 60000, capacity: 10 },
}); See the convex-helpers documentation for setup details.