Skip to main content

Protect Data

Enforce ownership in Convex and add Nuxt route protection for navigation.

Authentication does not secure a function by itself. Update the todo app so every document belongs to the authenticated Convex identity.

Add an owner to the schema

convex/schema.ts
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'

export default defineSchema({
  todos: defineTable({
    ownerId: v.string(),
    text: v.string(),
    completed: v.boolean(),
    createdAt: v.number(),
  }).index('by_owner_created', ['ownerId', 'createdAt']),
})

Delete or migrate public development records before deploying this schema change. Do not assign existing records to the first user who reads them.

Require identity in the backend

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

async function requireIdentity(ctx: {
  auth: { getUserIdentity(): Promise<{ subject: string } | null> }
}) {
  const identity = await ctx.auth.getUserIdentity()
  if (!identity) throw new ConvexError({ code: 'UNAUTHENTICATED' })
  return identity
}

export const list = query({
  args: {},
  handler: async (ctx) => {
    const identity = await requireIdentity(ctx)
    return await ctx.db
      .query('todos')
      .withIndex('by_owner_created', (q) => q.eq('ownerId', identity.subject))
      .order('desc')
      .take(50)
  },
})

export const create = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const identity = await requireIdentity(ctx)
    const text = args.text.trim()
    if (!text || text.length > 200) {
      throw new ConvexError({ code: 'INVALID_TODO_TEXT' })
    }

    return await ctx.db.insert('todos', {
      ownerId: identity.subject,
      text,
      completed: false,
      createdAt: Date.now(),
    })
  },
})

The query only reads the caller's indexed documents. The mutation ignores any client-supplied owner and writes the validated identity.

Mark the query as required

ts
const {
  data: todos,
  status,
  error,
} = await useConvexQuery(
  api.todos.list,
  {},
  {
    auth: 'required',
  },
)

An anonymous required query stays idle instead of executing anonymously.

This is execution policy, not the backend check. Keep both.

Protect navigation

Configure the default sign-in route:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['better-convex-nuxt'],
  convex: {
    auth: {
      routeProtection: {
        redirectTo: '/auth',
        preserveReturnTo: true,
      },
    },
  },
})

Mark the todo page:

app/pages/index.vue
definePageMeta({ convexAuth: true })

The middleware waits for auth settlement and redirects anonymous navigation. It preserves a local return path in the redirect query parameter.

Verify the boundary

Test all three cases:

  1. Anonymous navigation redirects to /auth.
  2. An authenticated user can create and list their todos.
  3. A different authenticated user cannot list the first user's todos.

The third test proves backend isolation. The first proves route UX.