Authentication & Authorization
Authentication & Authorization
@websideproject/nuxt-auto-api provides a flexible multi-tier authorization system that works with any authentication provider. You control user context and permissions, while the module enforces authorization rules.
How It Works
- Your auth system sets
event.context.userandevent.context.permissions(via Nitro plugin) - @websideproject/nuxt-auto-api enforces authorization rules based on your configuration
- Works with any auth provider: better-auth, NextAuth, Lucia, custom JWT, etc.
Setup
1. Populate Auth Context
Create a Nitro plugin to set user context from your auth provider:
// server/plugins/auth.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
// Extract user from your auth system (session, JWT, API key, etc.)
const user = await getAuthenticatedUser(event)
if (user) {
// Set user in event context
event.context.user = user
// Set permissions array (roles, scopes, etc.)
event.context.permissions = user.roles || []
}
})
})
Required context fields:
event.context.user- User object (ornullif unauthenticated)event.context.permissions- Array of permission strings (e.g.,['user', 'editor'])
2. Configure Resource Authorization
Define authorization rules in your module's auth config:
// modules/blog/auth.ts
import { eq } from 'drizzle-orm'
import type { ResourceAuthConfig } from '@websideproject/nuxt-auto-api/types'
export const articlesAuth: ResourceAuthConfig = {
permissions: {
read: () => true, // Public read
create: ['editor', 'admin'], // Only editors and admins
update: ['editor', 'admin'],
delete: 'admin', // Only admins
},
listFilter: (table, ctx) => {
// SQL-level filter for list operations
if (ctx.user?.role === 'admin' || ctx.user?.role === 'editor') {
return undefined // No filter
}
return eq(table.published, true) // Non-editors see only published
},
objectLevel: async (article, ctx) => {
// Object-level check for get/update/delete
if (ctx.user?.role === 'admin') return true
if (ctx.operation === 'get') {
return article.published || ctx.user?.role === 'editor'
}
return ctx.user?.role === 'editor'
},
fields: {
// Field-level authorization
drafts: {
read: ['editor', 'admin'], // Only editors/admins can see drafts field
},
},
}
3. Register with @websideproject/nuxt-auto-api
// modules/blog/module.ts
import { defineNuxtModule, createResolver } from '@nuxt/kit'
import { useSchemaRegistry, createModuleImport } from '@websideproject/nuxt-auto-api/module'
export default defineNuxtModule({
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
const registry = useSchemaRegistry(nuxt)
nuxt.hook('autoApi:registerResources', () => {
registry.register('articles', {
schema: createModuleImport(resolver.resolve('./schema'), 'articles'),
authorization: createModuleImport(resolver.resolve('./auth'), 'articlesAuth'),
})
})
},
})
Authorization Tiers
1. Operation-Level (Permissions)
Control which operations users can perform:
permissions: {
read: ['user', 'admin'], // Array: user needs ANY of these
create: 'admin', // String: exact match required
update: (ctx) => {
// Function: custom logic
return ctx.user?.id === ctx.params.userId ||
ctx.permissions.includes('admin')
},
delete: () => true, // Allow all (rarely used)
}
Permission types:
string- User must have this exact permissionstring[]- User must have ANY of these permissionsFunction- Custom logic returningbooleanorPromise<boolean>() => true- Allow all (public)
2. SQL-Level List Filter
Most efficient way to filter list results - runs in the database:
listFilter: (table, ctx) => {
// Return SQL condition or undefined (no filter)
if (ctx.user?.role === 'admin') return undefined
// Filter to user's own records
return eq(table.userId, ctx.user?.id)
}
Benefits:
- ✅ Runs in SQL (fast)
- ✅ Correct pagination (total, limit, offset account for filtered data)
- ✅ Works with all query features (filters, sorting, relations)
Common patterns:
// Filter by owner
listFilter: (table, ctx) => {
if (ctx.user?.role === 'admin') return undefined
return eq(table.userId, ctx.user?.id)
}
// Filter by status
listFilter: (table, ctx) => {
if (ctx.user?.role === 'admin') return undefined
return eq(table.status, 'active')
}
// Combine conditions
import { and, eq, or } from 'drizzle-orm'
listFilter: (table, ctx) => {
if (ctx.user?.role === 'admin') return undefined
// Show published articles OR user's own drafts
return or(
eq(table.published, true),
and(
eq(table.published, false),
eq(table.authorId, ctx.user?.id)
)
)
}
3. Object-Level Authorization
Fine-grained control for individual items:
objectLevel: async (record, ctx) => {
// Called for: get, update, delete (per item)
// Called for: list (post-filter - prefer listFilter instead!)
// Admins can access anything
if (ctx.user?.role === 'admin') return true
// Users can only access their own records
return record.userId === ctx.user?.id
}
When to use:
- ✅ Individual operations (get, update, delete)
- ✅ Complex logic requiring async operations
- ✅ Checks involving external data or computed fields
Avoid for list operations - use listFilter instead for better performance.
4. Field-Level Authorization
Control access to specific fields:
fields: {
email: {
read: (ctx) => {
// Users can only see their own email
return ctx.user?.id === ctx.params.id ||
ctx.permissions.includes('admin')
},
write: (ctx) => {
// Only admins can change email
return ctx.permissions.includes('admin')
},
},
salary: {
read: ['admin', 'hr'], // Only admins and HR can see
write: 'admin', // Only admins can modify
},
}
HandlerContext
All authorization functions receive a HandlerContext object:
interface HandlerContext {
// Auth
user: AuthUser | null
permissions: string[]
authMethod?: string // How user authenticated (e.g., 'session', 'api-key')
// Request
event: H3Event
operation: 'list' | 'get' | 'create' | 'update' | 'delete'
resource: string
params: Record<string, string>
query: Record<string, any>
validated: { body?: any; query?: any }
// Database
db: any
schema: any
fullSchema?: any
adapter?: DatabaseAdapter
// Multi-tenancy
tenant?: {
id: string | number
field: string
canAccessAllTenants: boolean
}
// Internal
objectLevelCheck?: Function
listFilter?: Function
}
Best Practices
1. Prefer listFilter Over objectLevel for Lists
// ❌ INEFFICIENT: Post-filter in JavaScript
objectLevel: async (article, ctx) => {
if (ctx.user?.role === 'editor') return true
return article.published
}
// - Fetches 100 items
// - Filters 50 in JS
// - Shows "total: 100" (WRONG)
// ✅ EFFICIENT: SQL WHERE clause
listFilter: (table, ctx) => {
if (ctx.user?.role === 'editor') return undefined
return eq(table.published, true)
}
// - SQL: WHERE published = true
// - Fetches 50 items
// - Shows "total: 50" (CORRECT)
2. Separate Roles from Permissions
// ❌ DON'T: Check roles directly everywhere
permissions: {
update: (ctx) => ctx.user?.role === 'admin'
}
// ✅ DO: Use permission arrays
// server/plugins/auth.ts
event.context.permissions = user.roles || []
// modules/blog/auth.ts
permissions: {
update: ['admin', 'editor'] // Declarative, flexible
}
3. Use Functions for Complex Logic
permissions: {
update: async (ctx) => {
// Custom logic: owner OR admin
if (ctx.permissions.includes('admin')) return true
// Check ownership
const record = await ctx.db.query[ctx.resource].findFirst({
where: eq(ctx.schema[ctx.resource].id, ctx.params.id)
})
return record?.userId === ctx.user?.id
}
}
4. Combine Authorization Tiers
export const postsAuth: ResourceAuthConfig = {
// 1. Operation-level: coarse-grained
permissions: {
read: () => true, // Public
create: ['user', 'admin'], // Authenticated
update: ['user', 'admin'],
delete: 'admin',
},
// 2. SQL-level: efficient list filtering
listFilter: (table, ctx) => {
if (ctx.user?.role === 'admin') return undefined
return eq(table.userId, ctx.user?.id) // Own posts only
},
// 3. Object-level: fine-grained item access
objectLevel: async (post, ctx) => {
if (ctx.permissions.includes('admin')) return true
return post.userId === ctx.user?.id // Own posts only
},
// 4. Field-level: sensitive fields
fields: {
privateNotes: {
read: (ctx) => ctx.permissions.includes('admin'),
write: (ctx) => ctx.permissions.includes('admin'),
},
},
}
Common Patterns
Public Read, Authenticated Write
permissions: {
read: () => true, // Anyone
create: (ctx) => !!ctx.user, // Authenticated
update: (ctx) => !!ctx.user,
delete: (ctx) => !!ctx.user,
}
Owner or Admin
objectLevel: async (record, ctx) => {
return record.userId === ctx.user?.id ||
ctx.permissions.includes('admin')
}
Role-Based
permissions: {
read: ['user', 'editor', 'admin'],
create: ['editor', 'admin'],
update: ['editor', 'admin'],
delete: 'admin',
}
Organization/Tenant Member
objectLevel: async (record, ctx) => {
// Same organization OR superadmin
return record.organizationId === ctx.user?.organizationId ||
ctx.permissions.includes('superadmin')
}
Testing Authorization
// test/auth.test.ts
import { describe, it, expect } from 'vitest'
import { createTestContext } from './test-utils'
describe('Articles Authorization', () => {
it('allows public read', async () => {
const ctx = createTestContext({ user: null })
const canRead = articlesAuth.permissions.read(ctx)
expect(canRead).toBe(true)
})
it('blocks non-editors from creating', async () => {
const ctx = createTestContext({
user: { id: 1, role: 'user' },
permissions: ['user']
})
const canCreate = articlesAuth.permissions.create(ctx)
expect(canCreate).toBe(false)
})
it('filters unpublished articles for non-editors', () => {
const ctx = createTestContext({
user: { id: 1, role: 'user' },
permissions: ['user']
})
const filter = articlesAuth.listFilter(schema.articles, ctx)
expect(filter).toBeDefined()
})
it('allows editors to see all articles', () => {
const ctx = createTestContext({
user: { id: 1, role: 'editor' },
permissions: ['editor']
})
const filter = articlesAuth.listFilter(schema.articles, ctx)
expect(filter).toBeUndefined() // No filter
})
})
Integration Examples
Custom JWT Auth
// server/plugins/auth.ts
import jwt from 'jsonwebtoken'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET)
event.context.user = decoded.user
event.context.permissions = decoded.user.roles || []
} catch (error) {
// Invalid token - leave context.user as null
}
}
})
})
Session-Based Auth
// server/plugins/auth.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
const sessionId = getCookie(event, 'sessionId')
if (sessionId) {
const session = await getSession(sessionId) // Your session store
if (session?.userId) {
const user = await db.query.users.findFirst({
where: eq(users.id, session.userId)
})
event.context.user = user
event.context.permissions = user?.roles || []
}
}
})
})
API Key Auth
// server/plugins/auth.ts
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('request', async (event) => {
const apiKey = getHeader(event, 'x-api-key')
if (apiKey) {
const key = await db.query.apiKeys.findFirst({
where: and(
eq(apiKeys.key, apiKey),
eq(apiKeys.active, true)
),
with: { user: true }
})
if (key) {
event.context.user = key.user
event.context.permissions = key.permissions || []
event.context.authMethod = 'api-key'
}
}
})
})
See Also
- Better-Auth Integration - Using @websideproject/nuxt-auto-api with better-auth
- Multi-Tenancy - Tenant isolation patterns
- Handler Overrides - Custom endpoints with auth
Soft Deletes
Soft deletes mark records as deleted without removing them from the database.
Better-Auth Integration
Integrate @websideproject/nuxt-auto-api with better-auth for a complete authentication solution with sessions, OAuth, organizations, and more.