Skip to main content

Backend authorization

Enforce ownership, membership, and role rules inside Convex functions.

Only the backend can protect data. Every protected Convex function checks the caller and the requested resource.

Start with the live user

Use the helpers on the auth object that createBetterConvexAuth returns. They read the Better Auth session in the same Convex function, so a revoked or expired session, or a token of another class, gets no access.

HelperResult
auth.getUser(ctx)The live Better Auth user, or null.
auth.requireUser(ctx)The live user. Otherwise it throws ConvexError code UNAUTHENTICATED.
auth.getAuth(ctx)Better Auth plus session headers for auth.api calls. Mutations and actions only.

requireUser throws ConvexError({ code: 'UNAUTHENTICATED', message: 'Authentication required' }). getUser and requireUser each run one component query.

Do not authorize with ctx.auth.getUserIdentity() alone. It proves that a token was valid when Convex accepted it, but it does not check the live session.

Authenticate an HTTP action

An HTTP action receives the browser's session cookie, not a Convex token. Wrap it in auth.sessionHttpAction instead of reading the cookie yourself:

convex/http.ts
http.route({
  path: '/api/auth/app/export',
  method: 'POST',
  handler: auth.sessionHttpAction(async (ctx, { user, auth, headers }) => {
    // `headers` act as this session for server-side `auth.api` calls.
    return Response.json(await ctx.runQuery(internal.exports.forUser, { userId: user.id }))
  }),
})

The wrapper applies the same hardening as the /api/auth/* routes: the signed client IP from the auth proxy, Better Auth rate limiting, and a same-origin check for unsafe methods. It reads the session through Better Auth without the cookie cache and then runs the same live session check as getUser. A denied request gets 401 UNAUTHENTICATED or 403 FORBIDDEN and never reaches the handler. Mount the route under /api/auth/ so browser calls pass through the auth proxy.

Check the resource

convex/projects.ts
import { ConvexError, v } from 'convex/values'
import { mutation } from './_generated/server'
import { auth } from './auth'

export const rename = mutation({
  args: {
    projectId: v.id('projects'),
    name: v.string(),
  },
  handler: async (ctx, args) => {
    const user = await auth.requireUser(ctx)
    const project = await ctx.db.get(args.projectId)

    if (!project || project.ownerId !== user.id) {
      throw new ConvexError({ code: 'PROJECT_NOT_FOUND' })
    }

    await ctx.db.patch(project._id, { name: args.name.trim() })
  },
})

Returning the same not-found response for missing and unauthorized resources can avoid revealing that a resource exists.

Organization membership

For organization data:

  1. Resolve the authenticated user.
  2. Load membership by indexed organization/user fields or through the Better Auth organization API.
  3. Check the required permission.
  4. Load or write the resource only after access is established.

Keep the helper in your Convex code. Better Convex Nuxt does not define your roles. The organization permissions recipe shows a complete helper on Better Auth Organization data.

Frontend capability context

A query may return the caller's role and allowed UI capabilities. This prevents rendering controls that will fail, but protected mutations repeat the backend check.

Test the matrix

At minimum, test:

  • anonymous caller;
  • resource owner/member;
  • authenticated non-member;
  • wrong organization;
  • missing resource;
  • each role that changes a permission.

Tests should call Convex functions directly. A browser redirect does not verify backend denial.