Nuxt Going Full-Stack: How to Handle Authorization?

When building an application, you will need to allow or restrict access to certain parts or certain data based on the user's role or permissions. This is called authorization.

This part, in any application, is crucial for security reasons. You don't want to expose sensitive data, like a user's email address or password, or leak private information that could compromise your users privacy and your business.

Just a reminder: authentication is about verifying the identity of a user, while authorization is about granting or denying access to resources based on the user's role or permissions.

While building Orion, I had to deal with this issue and I've tried different approaches. In this article, I'll share with you many ways to handle authorization in a NuxtHub application, from the simplest to the most advanced. It's important to note that the most advanced way is not the best for all applications.

Orion is a community-driven collection of templates for your next project, from landing pages to complete web applications. NuxtHub is deployment and administration platform for Nuxt, powered by Cloudflare.

Building a Full-Stack Nuxt Application on Twitch

The Context

Imagine this endpoint in your Nuxt application:

ts
export default defineEventHandler(async (event) => {
  const id = getRouteParam(event, 'id')

  const publication = await db.query.publications.findOne({
    where: { id },
    columns: {
      title: true,
      content: true,
      status: true,
      authorId: true,
    },
  })

  return publication
})

You can access this endpoint by calling GET /api/publication/<id>. This endpoint returns a publication. At first glance, there is no problem with this endpoint. But if you look closer, you will see that a publication has a status. This status can be draft, published, or deleted.

The problem is that anyone can access this endpoint and see the content of a draft publication or a deleted publication. Some could access the content of a publication before it's published and depending on the content, it could be important information like a press release or a product launch. This issue could ruin your business.

This is a security issue and you need to restrict access to this endpoint using the principle of least privilege. Security is not a feature, it must be by design.

Authenticated or Not

All of this part is based on the fact that you use Nuxt nuxt-auth-utils package. But in reality, it does not matter.

The first step to protect this endpoint is to check if the user is authenticated. If the user is not authenticated, I want to return a 401 Unauthorized error.

By diving into the package nuxt-auth-utils, I found a server utility called requireUserSession. This utility can be used at the beginning of an endpoint to try to retrieve the user's session and throw a 401 Unauthorized error if there is no session, the user is not authenticated.

ts
export default defineEventHandler(async (event) => {
  await requireUserSession(event)

  const publication = {} // ...

  return publication
})

This is a first step to protect the endpoint against unauthenticated users. But if anyone can create an account, they can access the content of a draft publication. I need to go further.

Only Admins

Based on requireUserSession, I can easily create a new utility called requireAdminSession. This session will check if the user is authenticated and if the user is an admin. If the user is not an admin, I want to return a 403 Forbidden error. Unauthorized vs Forbidden: Unauthorized is about authentication, Forbidden is about authorization.

ts
export async function requireAdminSession(event: H3Event, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
  const userSession = await requireUserSession(event)
  const { user } = userSession

  if (user.roleType !== 'admin') {
    throw createError({
      statusCode: opts.statusCode || 403,
      message: opts.message || 'Unauthorized',
    })
  }

  return userSession as UserSessionRequired
}

Then, I can replace requireUserSession by requireAdminSession in the endpoint.

ts
export default defineEventHandler(async (event) => {
  await requireAdminSession(event)

  const publication = {} // ...

  return publication
})

Now, only admins, users with the role admin, can access the content of a publication.

But that's not really better because if the publication is published, I want to allow everyone to access it. Actually, only admins can access the content of a publication, no matter the status of the publication.

Problems with These Approaches

With the previous approaches, there is not enough flexibility to have a fine-grained authorization system. I can't handle the case where the author of the publication can access the content of the publication, even if the publication is not published.

To solve this, I could write another utility called requirePublicationAccess:

ts
export async function requirePublicationAccess(event: H3Event, publication: Publication, opts: { statusCode?: number, message?: string } = {}): Promise<UserSessionRequired> {
  const userSession = await getUserSession(event)
  const { user } = userSession

  if (publication.status === 'published')
    return userSession

  if (!userSession) {
    throw createError({
      statusCode: opts.statusCode || 401,
      message: opts.message || 'Unauthorized',
    })
  }

  if (publication.authorId === user.id)
    return userSession

  if (user.roleType === 'admin')
    return userSession

  throw createError({
    statusCode: opts.statusCode || 403,
    message: opts.message || 'Forbidden',
  })
}

This is ok until I have to handle more endpoints. This approach will generate a lot of duplicated code on the error management. This code is also more difficult to test because you have to handle a whole H3 event.

Without a better approach, I continue to develop Orion but quickly, I encountered another problem: I need to handle the same authorization logic on the client side. I do not want to show an edit button to a user who is not the author of the publication or an admin and I do not want to hide this edit button from the admin if I decide to allow him to edit the publication on the server. Like for forms validation, duplication of logic can result in a lot of inconsistencies between the client and the server and frustration for clients. Utility I create until now are only for the server side because of the usage of requireUserSession and getUserSession.

Let's do a step aside to understand how this problem could be solved. Authorization is 3 things:

  • allow access to a resource
  • deny access to a resource
  • authorize access to a resource

The first two are simple conditions that return a boolean. They are the answer to questions like "Can I access this publication? Yes or No". Then the developer must handle this answer manually. The third one gives access to the resource. To the question "Can I access this publication?", there is no "yes" or "no" but nothing happens if it's a "yes" and an error is thrown if it's a "no". The authorize sends an error automatically.

All of this has nothing related to client or server and even less to the framework you use or the authentication system. With that in mind, I started to work on a local module for Orion to handle authorization in a more flexible way. After a few days of work, I'm proud to introduce nuxt-authorization.

Originally, I wanted to make a Nitro and a Nuxt package but Nitro modules are not ready yet. Even though, I create some PRs to the Nitro repository to improve the modules system. One of them is the module author guide.

Introducing nuxt-authorization

This module is a simple but powerful way to handle authorization in a Nuxt application, on both the client and the server. It's authentication system agnostic but can easily be used with nuxt-auth-utils.

To learn how to use it, see the GitHub repository: nuxt-authorization. If you want to learn more about the design and the implementation, continue reading.

The module is still at an early stage of development and if you have any feedback or ideas to improve it, feel free to open an issue on the GitHub repository or reach out to me on X.

Problems I Want to Solve

Before explaining what I achieved with nuxt-authorization, let's explore the problems I wanted to solve.

Similar to the forms validation, I want to have both a single source of truth and a single API on both the app and the server. This is really important to avoid inconsistencies between the app and the server.

I also want to be able to define the authorization logic in a simple way and grouped by resource. I think it's easier to read, to maintain, and to keep consistency between the different parts of the application, like forms validation.

The authorization logic must be flexible enough to handle a wide range of use cases, from an allow or deny condition to a more complex authorize condition. The error thrown by the authorize condition must be customizable to provide a better user experience. Not all unauthorized errors must be a 403 Forbidden.

Finally, I want to have components to facilitate the integration of the authorization logic in the templates. I want to be able to show or hide a button based on the authorization logic, for example.

Design

The module is composed of 2 parts: the ability and the bouncer.

The abilities are the rules that define the authorization logic. They take a user and one or more resources and return a deny or an allow condition. They can be grouped by resource but stay independent of each other.

ts
export const editBook = defineAbility((user: User, book: Book) => {
  return user.id === book.authorId
})

In this example, I only authorize the author of the book to edit it. By default, abilities are only executed if the user is authenticated but it's possible to allow guests to access some resources.

ts
export const listBooks = defineAbility((user: User | null) => {
  return true
})

With that, I allow everyone to list books, even unauthenticated users.

The defineAbility function is a factory that creates an ability but nothing more. I need to use it with bouncer functions.

In a server endpoint, I can use the authorize function to authorize the access to a resource based on the abilities.

ts
export default defineEventHandler(async (event) => {
  await authorize(event, listBooks)

  const books = await db.query.books.findMany()

  return books
})

The authorize function will throw a 403 Forbidden error if the user is not allowed to list books. You can have more granular control using the allows or denies functions.

ts
export default defineEventHandler(async (event) => {
  if (await denies(event, listBooks, book))
    return []

  const books = await db.query.books.findMany()

  return books
})

Instead of throwing an error, I decide to return an empty array if the user is not allowed to list books. Your business logic will determine the best way to handle unauthorized access.

For example, you could return a 404 Not Found error if the user is not allowed to access a specific resource to preserve the privacy of your data. Knowing that the resource exists could be a security issue.

This customization is possible within the abilities thanks to the allow and deny functions.

ts
export const viewBook = defineAbility((user: User, book: Book) => {
  if (book.status === 'draft') {
    return deny({
      statusCode: 404,
      message: 'Not Found',
    })
  }

  return allow()
})

Now, the authorize function will throw a 404 Not Found error if the user is not allowed to view a draft book instead of the default 403 Forbidden.

On the client side, I can also use the allows, denies and authorize functions to handle the authorization logic.

In addition to these functions, the module provides two components: Can and Cannot. These components allow you to show or hide a part of the template based on the authorization logic.

vue
<template>
  <Can :ability="editBook" :args="[book]">
    <button>Edit</button>
  </Can>
</template>

Learn more at the GitHub repository: nuxt-authorization.

Final Thoughts

This package, both the code and the design, is heavily inspired by the Adonis Bouncer. It's a well written package and I think reinventing the wheel every time is unnecessary.

I'm already using this package in Orion and it's really pleasant to work with. I'm sure there is still room for improvement but this module is a first step to empower Nuxt developers and to push the full-stack part of Nuxt. Feel free to contribute and to suggest new features or improvements.

Orion is a community-driven collection of templates for your next project, from landing pages to complete web applications.

I hope this article and the module will help you to handle authorization in your Nuxt application to prevent and restrict unauthorized access to your data. Security is not a feature.

Happy coding!

Back to posts
Support my work
Follow me on