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.

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

  1. Your auth system sets event.context.user and event.context.permissions (via Nitro plugin)
  2. @websideproject/nuxt-auto-api enforces authorization rules based on your configuration
  3. 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 (or null if 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 permission
  • string[] - User must have ANY of these permissions
  • Function - Custom logic returning boolean or Promise<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

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.