Skip to main content

Permission Guards

Permissions in Pikku run before your function executes. They're boolean checks that determine whether a request should proceed - if any permission returns false, the request is rejected with a 403 Forbidden.

Permissions run in parallel, so they should be independent checks that don't depend on execution order.

Your First Permission​

A permission is a function that returns a boolean:

import { pikkuPermission } from '#pikku/pikku-types.gen.js'

export const requireAuth = pikkuPermission(async (_services, _data, session) => {
return session?.userId != null
})

export const requireAdmin = pikkuPermission(async (_services, _data, session) => {
return session?.role === 'admin'
})

Use them in your function:

import { pikkuFunc } from '#pikku/pikku-types.gen.js'

export const deleteUser = pikkuFunc<{ userId: string }, void>({
func: async ({ database }, data) => {
await database.delete('users', { where: { id: data.userId } })
},
permissions: {
auth: requireAuth,
admin: requireAdmin
},
docs: {
summary: 'Delete a user',
tags: ['users']
}
})

Both permissions must pass for the function to execute. If either returns false, the request is rejected.

Permission Signature​

pikkuPermission<DataType>(
async (services, data, session) => boolean
)

Parameters:

  • services - Your application services (destructure what you need)
  • data - The input data (typed with DataType)
  • session - The current user session (from userSession.get())

Return true to allow access, false to deny with 403.

Data-Based Permissions​

Permissions can inspect the request data:

export const requireOwnership = pikkuPermission<{ resourceId: string }>(
async ({ database }, data, session) => {
if (!session?.userId) return false

const resource = await database.query('resources', {
where: { id: data.resourceId }
})

return resource?.ownerId === session.userId
}
)

Use it in a function with matching data type:

export const updateResource = pikkuFunc<
{ resourceId: string; content: string },
Resource
>({
func: async ({ database }, data) => {
return await database.update('resources', {
where: { id: data.resourceId },
data: { content: data.content }
})
},
permissions: {
auth: requireAuth,
owner: requireOwnership // Uses resourceId from data
},
docs: {
summary: 'Update a resource',
tags: ['resources']
}
})

Permission Groups​

You can compose multiple permissions:

export const requirePremium = pikkuPermission(async ({ database }, _data, session) => {
if (!session?.userId) return false

const user = await database.query('users', {
where: { id: session.userId }
})

return user?.isPremium === true
})

// Use multiple permissions together
export const getPremiumContent = pikkuFunc<{ contentId: string }, Content>({
func: async ({ database }, data) => {
return await database.query('premium_content', {
where: { id: data.contentId }
})
},
permissions: {
auth: requireAuth,
premium: requirePremium
},
docs: {
summary: 'Get premium content',
tags: ['content']
}
})

Complex Permissions​

Permissions can perform complex queries:

export const withinQuota = pikkuPermission(async ({ database }, _data, session) => {
if (!session?.userId) return false

const usage = await database.query('api_usage', {
where: {
userId: session.userId,
date: new Date().toISOString().split('T')[0]
}
})

return (usage?.requestCount || 0) < 1000 // Daily limit
})

export const activeSubscription = pikkuPermission(
async ({ database }, _data, session) => {
if (!session?.userId) return false

const sub = await database.query('subscriptions', {
where: { userId: session.userId }
})

if (!sub) return false

return new Date(sub.expiresAt) > new Date()
}
)

HTTP-Specific Permissions​

For HTTP routes, you can apply permissions at the route level or globally:

import { wireHTTP } from '#pikku/pikku-types.gen.js'

// Route-level permissions
wireHTTP({
method: 'delete',
route: '/users/:userId',
func: deleteUser,
permissions: {
auth: requireAuth,
admin: requireAdmin
}
})

Or apply to all routes with a prefix:

import { addHTTPPermission } from '#pikku/pikku-types.gen.js'

// All /admin routes require authentication and admin role
addHTTPPermission('/admin', {
auth: requireAuth,
admin: requireAdmin
})

// All routes require authentication
addHTTPPermission('*', {
auth: requireAuth
})

See HTTP Router for more on addHTTPPermission.

Error Handling​

If a permission throws an error, it's treated as a server error (500), not unauthorized (403):

// âś… Good - returns false for unauthorized
export const requireOwnership = pikkuPermission(async ({ database }, data, session) => {
if (!session?.userId) return false

try {
const resource = await database.query('resources', {
where: { id: data.resourceId }
})
return resource?.ownerId === session.userId
} catch (error) {
// Database error - let it throw (500)
throw error
}
})

// ❌ Bad - throws for unauthorized
export const requireOwnership = pikkuPermission(async ({ database }, data, session) => {
if (!session?.userId) {
throw new Error('Not authenticated') // This returns 500, not 403!
}
// ...
})

Use return false for authorization failures. Only throw for actual errors.

Permission Logic and Execution​

Important: Permissions Run in Parallel

All permissions execute concurrently, not sequentially. This means:

  • Don't rely on execution order - Permissions may run in any order
  • Avoid side effects - Don't modify shared state or depend on other permissions running first
  • Keep them independent - Each permission should be a self-contained check

OR Logic (Default)​

When you list multiple permissions as object keys, any one can pass (OR logic):

permissions: {
admin: requireAdmin, // Can pass if admin
owner: requireOwner, // OR can pass if owner
moderator: requireModerator // OR can pass if moderator
}
// Request proceeds if ANY permission returns true

This is useful when multiple roles or conditions should grant access. For example, both admins and resource owners should be able to edit a resource.

AND Logic (Arrays)​

To require multiple permissions to pass simultaneously, use arrays:

permissions: {
authAndVerified: [requireAuth, requireEmailVerified] // Both must pass
}
// Request proceeds only if ALL permissions in the array return true

Don't depend on execution order - each permission should be an independent check.

Auth vs Permissions​

auth flag controls whether a session is required:

// Requires session to exist (default)
export const getProfile = pikkuFunc({
func: async ({ database }, _data, session) => {
// session is guaranteed to exist
return await database.query('users', { where: { id: session.userId } })
},
auth: true, // Default
docs: {
summary: 'Get user profile',
tags: ['users']
}
})

// No session required
export const getPublicContent = pikkuFunc({
func: async ({ database }, data) => {
return await database.query('content', { where: { id: data.id } })
},
auth: false,
docs: {
summary: 'Get public content',
tags: ['content']
}
})

permissions run additional checks after auth:

export const deleteAccount = pikkuFunc({
func: async ({ database }, _data, session) => {
await database.delete('users', { where: { id: session.userId } })
},
auth: true, // Session required
permissions: {
verified: requireEmailVerified, // Additional check
notBanned: requireNotBanned // Additional check
},
docs: {
summary: 'Delete account',
tags: ['users']
}
})

Reusable Permissions​

Define permissions once, reuse everywhere:

// permissions.ts
export const requireAuth = pikkuPermission(async (_services, _data, session) => {
return session?.userId != null
})

export const requireAdmin = pikkuPermission(async (_services, _data, session) => {
return session?.role === 'admin'
})

export const requireOwnership = pikkuPermission<{ resourceId: string }>(
async ({ database }, data, session) => {
if (!session?.userId) return false
const resource = await database.query('resources', {
where: { id: data.resourceId }
})
return resource?.ownerId === session.userId
}
)

Then import and use:

import { requireAuth, requireAdmin, requireOwnership } from './permissions.js'

export const updateResource = pikkuFunc({
func: async ({ database }, data) => { /* ... */ },
permissions: {
auth: requireAuth,
owner: requireOwnership
},
docs: {
summary: 'Update resource',
tags: ['resources']
}
})

export const deleteUser = pikkuFunc({
func: async ({ database }, data) => { /* ... */ },
permissions: {
auth: requireAuth,
admin: requireAdmin
},
docs: {
summary: 'Delete user',
tags: ['users']
}
})

Best Practices​

Keep permissions focused - One check per permission:

// âś… Good - single responsibility
export const requireAuth = pikkuPermission(...)
export const requireAdmin = pikkuPermission(...)
export const requireVerified = pikkuPermission(...)

permissions: {
auth: requireAuth,
admin: requireAdmin,
verified: requireVerified
}

// ❌ Bad - doing too much
export const requireEverything = pikkuPermission(async (services, data, session) => {
if (!session?.userId) return false
if (session.role !== 'admin') return false
if (!session.emailVerified) return false
// Too many concerns
})

Optimize expensive checks - Cache when possible:

// âś… Good - caches subscription check
export const requireSubscription = pikkuPermission(
async ({ cache, database }, _data, session) => {
if (!session?.userId) return false

const cacheKey = `sub:${session.userId}`
const cached = await cache.get(cacheKey)
if (cached !== null) return cached === 'true'

const sub = await database.query('subscriptions', {
where: { userId: session.userId }
})

const isActive = sub && new Date(sub.expiresAt) > new Date()
await cache.set(cacheKey, isActive ? 'true' : 'false', { ttl: 300 })

return isActive
}
)

Return false, don't throw - For authorization failures:

// âś… Good
if (!session?.userId) return false

// ❌ Bad
if (!session?.userId) throw new Error('Unauthorized') // Returns 500, not 403

Next Steps​

  • Functions - Understanding Pikku functions
  • HTTP Router - HTTP-specific permissions with addHTTPPermission
  • Middleware - Request/response transformation