User-Scoped Data
Associate rows with users, understand ctx.auth, and guard mutations.
// valet/schema.ts
import { defineSchema, defineTable, v, authTables } from 'valet-dev/server'
export default defineSchema({
...authTables,
notes: defineTable({
title: v.string(),
content: v.string(),
userId: v.string(),
}).sync({
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
mode: 'full',
}),
})This guide covers the most common auth questions: what type to use for userId, when ctx.auth is available, how authTables relates to external providers, and how to guard mutations.
Use v.string() for userId
The userId field on your tables should be v.string(), not v.id('_auth_users').
ctx.auth.userId is the sub claim from the JWT — an opaque string from whatever auth provider issued the token. For Valet's built-in auth, this is the internal user ID. For external providers (Firebase, Clerk, Auth0), it's their user ID format (e.g., Firebase UIDs, Clerk user IDs).
Using v.id('_auth_users') would incorrectly couple your schema to Valet's internal auth tables, which don't exist when using an external provider.
// valet/schema.ts
import { defineSchema, defineTable, v } from 'valet-dev/server'
export default defineSchema({
// GOOD: works with any auth provider
tasks: defineTable({
title: v.string(),
userId: v.string(),
}),
// BAD: only works with Valet built-in auth
// tasks: defineTable({
// title: v.string(),
// userId: v.id('_auth_users'),
// }),
})When ctx.auth is available
ctx.auth has type AuthInfo:
interface AuthInfo {
userId: string
claims?: Record<string, unknown>
}Its availability depends on where your code runs:
| Context | Type | When it's defined |
|---|---|---|
| Sync filter | ctx.auth (required) | Always — filters only run for authenticated connections. |
| Query handler | ctx.auth?: AuthInfo | Present when the client is authenticated. undefined for unauthenticated requests. |
| Mutation handler | ctx.auth?: AuthInfo | Present when the client is authenticated. undefined for unauthenticated requests. |
In sync filters, ctx.auth is always available because the server only evaluates filters for connections that have a valid auth token. You can safely use ctx.auth.userId without checking for undefined.
In query and mutation handlers, ctx.auth is optional. The handler may be called by an unauthenticated client, so you must check before using it.
authTables vs external auth
authTables provides Valet's built-in user, session, and account tables for OAuth and password-based auth. They're only needed when using Valet's built-in auth system.
If you use an external auth provider (Firebase, Clerk, Auth0), you don't need authTables at all. ctx.auth is populated by validating the JWT that the client sends when connecting — it works the same regardless of which provider issued the token.
| Setup | authTables needed? | auth.ts needed? | ctx.auth works? |
|---|---|---|---|
| Valet built-in auth | Yes | Yes | Yes |
| Firebase / Clerk / Auth0 | No | No | Yes (via JWT) |
To add external auth without authTables, use the --external flag:
# terminal
bunx valet-dev auth init --externalGuarding mutations
Always throw when a mutation requires authentication but ctx.auth is missing. Don't fall back to a placeholder like 'anonymous'.
// valet/tasks.ts
import { defineMutation, v } from '../_generated/valet/api'
export const create = defineMutation({
args: {
title: v.string(),
},
handler: async (ctx, args) => {
// GOOD: throw if unauthenticated
if (!ctx.auth) {
throw new Error('Authentication required')
}
return ctx.db.insert('tasks', {
title: args.title,
completed: false,
userId: ctx.auth.userId,
})
},
})The init scaffold uses ctx.auth?.userId ?? 'anonymous' as a convenience for getting started quickly. Replace this with an explicit check before going to production.
Complete example
Putting it all together: schema with auth, a guarded mutation, a sync filter, and a client provider.
// valet/schema.ts
import { defineSchema, defineTable, v, authTables } from 'valet-dev/server'
export default defineSchema({
...authTables,
tasks: defineTable({
title: v.string(),
completed: v.boolean(),
userId: v.string(),
})
.index('by_completed', ['completed'])
.sync({
filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
mode: 'full',
}),
})// valet/tasks.ts
import { defineMutation, defineQuery, v } from '../_generated/valet/api'
export const list = defineQuery({
args: {},
execution: 'local',
handler: async (ctx) => {
return ctx.db.query('tasks').collect()
},
})
export const create = defineMutation({
args: {
title: v.string(),
},
handler: async (ctx, args) => {
if (!ctx.auth) {
throw new Error('Authentication required')
}
return ctx.db.insert('tasks', {
title: args.title,
completed: false,
userId: ctx.auth.userId,
})
},
})// App.tsx
import { useAuth } from 'valet-dev/react'
import { ValetProvider, useQuery, useMutation } from '../_generated/valet/react'
import { api } from '../_generated/valet/api'
function App() {
return (
<ValetProvider url={process.env.VALET_PROJECT_URL!} auth>
<AuthGate />
</ValetProvider>
)
}
function AuthGate() {
const { isAuthenticated, isLoading } = useAuth()
if (isLoading) return <div>Loading...</div>
if (!isAuthenticated) return <div>Please sign in</div>
return <TaskList />
}
function TaskList() {
const { data: tasks } = useQuery(api.tasks.list, {})
const createTask = useMutation(api.tasks.create)
return (
<div>
<button onClick={() => createTask.mutate({ title: 'New task' })}>
Add task
</button>
<ul>
{tasks?.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
</div>
)
}