Security
Best practices for securing your server actions in production.
CSRF Protection
nuxt-actions provides a built-in csrfMiddleware() for CSRF token protection on mutation actions:
const protectedAction = createActionClient()
.use(csrfMiddleware())
.schema(z.object({ title: z.string() }))
.action(async ({ input }) => {
return db.createPost(input)
})Custom configuration:
csrfMiddleware({
cookieName: '__csrf', // Default: '_csrf'
headerName: 'x-xsrf-token', // Default: 'x-csrf-token'
tokenLength: 64, // Default: 32
})On safe requests (GET, HEAD), a token is set as an httpOnly cookie. On mutation requests (POST, PUT, PATCH, DELETE), the middleware validates that the token in the request header matches the cookie value.
See the csrfMiddleware API reference for full details.
Authentication Middleware
Protect actions with authentication middleware:
// server/utils/auth.ts
export const authMiddleware = defineMiddleware(async ({ event, next }) => {
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createActionError({
code: 'UNAUTHORIZED',
message: 'Authentication required',
statusCode: 401,
})
}
// Verify token (your logic here)
const user = await verifyToken(token)
if (!user) {
throw createActionError({
code: 'UNAUTHORIZED',
message: 'Invalid or expired token',
statusCode: 401,
})
}
return next({ ctx: { user } })
})Apply it to protected actions:
// server/actions/create-todo.ts
export default defineAction({
input: z.object({ title: z.string().min(1) }),
middleware: [authMiddleware],
handler: async ({ input, ctx }) => {
// ctx.user is typed and available
return db.todos.create({ ...input, userId: ctx.user.id })
},
})Or use the builder pattern for shared middleware:
// server/utils/action-client.ts
export const protectedAction = createActionClient()
.use(authMiddleware)
.use(rateLimitMiddleware)// server/actions/create-todo.ts
export default protectedAction
.schema(z.object({ title: z.string().min(1) }))
.action(async ({ input, ctx }) => {
return db.todos.create({ ...input, userId: ctx.user.id })
})Input Validation
Always validate input. Server actions accept arbitrary user input — never trust it:
// Good: Input is validated before reaching the handler
export default defineAction({
input: z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
}),
handler: async ({ input }) => {
// input.title and input.email are validated
},
})
// Bad: No validation — input could be anything
export default defineAction({
handler: async ({ input }) => {
// input is unknown — SQL injection, XSS, etc. are all possible
},
})Rate Limiting
Use the built-in rateLimitMiddleware() to prevent abuse:
const limitedAction = createActionClient()
.use(rateLimitMiddleware({ limit: 10, window: 60000 }))
.schema(z.object({ email: z.string() }))
.action(async ({ input }) => {
return db.findUser(input.email)
})Custom key function:
rateLimitMiddleware({
limit: 100,
window: 60000,
keyFn: (event) => event.context.auth?.userId ?? getRequestIP(event) ?? 'unknown',
message: 'Rate limit exceeded. Please try again later.',
})See the rateLimitMiddleware API reference for full configuration options.
Error Handling
Use handleServerError to prevent leaking internal details:
export default defineAction({
input: z.object({ id: z.number() }),
handleServerError(error) {
// Log the real error server-side
console.error('Action error:', error)
// Return a safe error to the client
return {
code: 'SERVER_ERROR',
message: 'Something went wrong',
statusCode: 500,
}
},
handler: async ({ input }) => {
// If this throws, handleServerError catches it
return db.todos.findOrFail(input.id)
},
})In production, nuxt-actions automatically hides internal error details — unknown errors return a generic INTERNAL_ERROR message. In development (import.meta.dev), the full error is logged to the console.
File-based Security
- Underscore-prefixed files (
_middleware.ts,_utils.ts) are not registered as actions — use them for shared middleware and utilities - Symlinks are skipped during directory scanning to prevent traversal attacks
- Dot-files and test files are excluded from scanning
- File names are validated against a safe pattern (
/^\w[\w.-]*\.ts$/)
Streaming Actions
Streaming actions (defineStreamAction) use Server-Sent Events. Consider:
- Timeouts: Implement server-side timeouts to prevent long-running streams from consuming resources
- Authentication: Apply the same auth middleware as regular actions
- Error masking: Use
handleServerErrorto prevent leaking internal errors via SSE events