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.
| Helper | Result |
|---|---|
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:
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
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:
- Resolve the authenticated user.
- Load membership by indexed organization/user fields or through the Better Auth organization API.
- Check the required permission.
- 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.