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:

  1. Authentication (requireAdminRead / requireAdminWrite) -- verify the caller is who they claim to be
  2. 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: true are 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-empty
    • minLength / maxLength — string length bounds
    • maxCharacters — character count limit (for rich-text fields)
    • min / max — numeric bounds
    • pattern — regex match, with ReDoS protection (overly complex patterns are rejected)
    • email — valid email address format
    • url — valid URL format
    • color — valid CSS hex color
    • point — 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.

DeprecatedReplacement
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:

  • [ ] requireAdminRead and requireAdminWrite check admin role claims, not just identity
  • [ ] getUserRoles is wired and returns role slugs from your RBAC tables
  • [ ] rbacFailOpen is false (the default)
  • [ ] convex/auth.config.ts issuer domain matches your auth provider exactly
  • [ ] Upload endpoints are protected by auth middleware
  • [ ] Upload allowedMimeTypes is restricted to types your app actually needs
  • [ ] Upload maxFileSizeBytes is set to a reasonable limit
  • [ ] Template stubs in convex/admin.ts have been replaced with real implementations
  • [ ] Component functions (packages/vextro/convex/) are not directly exposed to clients
  • [ ] Webhook routes are protected with requireAdminRead (not createWebhookSecretGuard, 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.

Previous
Authentication (Clerk)