developers

Fastify and TypeScript Tutorial: Secure a Content API

Learn how to use TypeScript and Auth0 to secure a feature-complete Fastify API. Tutorial on how to use Auth0 to implement authorization in Fastify.

Apr 21, 202518 min read

In building the Content API in the first part of this tutorial, you've learned how to build API endpoints using Fastify, a high-performance Node.js web framework, and TypeScript. You've defined data models and structured services and built modular routes. Now, this tutorial will guide you through securing this API using Auth0.

One of the key requirements for many APIs is controlling who can perform certain actions, like creating or modifying data. For the Content API, only authorized users should be able to create, update, or delete documents. Auth0 provides a robust and quick way to manage user identities and control access without building an authentication system from scratch.

Set Up an Authorization Service with Auth0

Auth0 simplifies managing authorization for your applications. If you haven't already, you'll need to sign up for a free Auth0 account.

Think of Auth0 as a flexible, plug-and-play solution for authentication and authorization. It saves your team the significant effort, cost, and security risks associated with building your own identity management system. Auth0 offers extensive documentation and SDKs to help you integrate it smoothly into your tech stack.

After you create your account, you'll create an Auth0 Tenant, which is a container that Auth0 uses to store your identity service configuration and your users in isolation — no other Auth0 customer can peek into or access your tenant. It's similar to you being a tenant in an apartment building. Auth0 looks after the building while the apartment is all yours to live in and customize. However, each apartment is fully isolated (no windows, soundproof walls, etc.) so that neighbors can't intrude on your privacy.

After creating your tenant, you need to create an API register with Auth0, which is an API that you define within your Auth0 tenant and that you can consume from your applications to process authentication and authorization requests.

After creating your account, head to the APIs section in the Auth0 Dashboard and hit the Create API button.

Then, in the form that Auth0 shows:

  • Add a Name to your API:

    Content API
    .

  • Set its Identifier to

    http://content-api.example.com
    .

  • Leave the signing algorithm as

    RS256
    as it's the best option from a security standpoint.

Identifiers are unique strings that help Auth0 differentiate between your different APIs. We recommend using URLs as they facilitate predictably creating unique identifiers; however, Auth0 never calls these URLs.

With these values in place, hit the Create button.

Your Fastify API needs two key pieces of information to identify itself with Auth0: an Audience and a Domain value. The best place to store these values is within the

.env
file of your project.

Open

.env
and add the following keys to it:

PORT=8080
# Example: Allow a client running on these URLs to access your API
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Prisma connection string
DATABASE_URL="file:./dev.db"
AUTH0_DOMAIN=
AUTH0_AUDIENCE=

Head back to your Auth0 API page, and follow these steps to get the Auth0 Audience:

Get the Auth0 Audience to configure an API

  1. Click on the "Settings" tab.

  2. Locate the "Identifier" field and copy its value.

  3. Paste the "Identifier" value as the value of

    AUTH0_AUDIENCE
    in
    .env
    .

Now, follow these steps to get the Auth0 Domain value:

Get the Auth0 Domain to configure an API

  1. Click on the "Test" tab.
  2. Locate the section called "Asking Auth0 for tokens from my application".
  3. Click on the cURL tab to show a mock
    POST
    request.
  4. Copy your Auth0 domain, which is part of the
    --url
    parameter value:
    tenant-name.region.auth0.com
    .
  5. Paste the Auth0 domain value as the value of
    AUTH0_DOMAIN
    in
    .env
    .
Tips to get the Auth0 Domain
  • The Auth0 Domain is the substring between the protocol,

    https://
    and the path
    /oauth/token
    .

  • The Auth0 Domain follows this pattern:

    tenant-name.region.auth0.com
    .

  • The

    region
    subdomain (
    au
    ,
    us
    , or
    eu
    ) is optional. Some Auth0 Domains don't have it.

  • Click on the image above if you have any doubts about how to get the Auth0 Domain value.

Create a Fastify Authentication Plugin

You extend functionality in Fastify using plugins. As such, you'll integrate the

@auth0/auth0-fastify-api
SDK as a plugin to handle authentication. When a user logs in to an application secured by Auth0, Auth0 issues them an access token in JSON Web Token (JWT) format that the client can use as a credential to identify itself with the server, which in this case is your Fastify API server.

When a client wants to access a protected route, it must include an access token in the

Authorization
header as a
Bearer
token. Your Fastify application will use the Auth0 plugin to check if the access token is:

  1. Valid: Has the access token been issued by your Auth0 tenant?
  2. Not Expired: Is the access token still within its valid time frame?
  3. Intended for our API: Does the access token's audience (
    aud
    ) claim match your API's Auth0 identifier— the
    AUTH0_AUDIENCE
    value?

If the access token passes all these checks, the plugin allows the request to proceed. If not, it rejects the request with a

401 Unauthorized
status, preventing access to the protected route handler. However, if the
Authorization
header is missing or it doesn't have a
Bearer
token value defined, the Auth0 plugin will reject the request with a
400 Bad Request
status.

Install the Auth0 Fastify SDK

First, install the SDK as a project dependency:

npm install @auth0/auth0-fastify-api

Register the Auth0 Plugin

Now, let's wire the plugin into the Fastify application defined in the

src/app.ts
file.

Start by importing the Auth0 Fastify plugin right below the

Helmet
one:

import { join } from "node:path";
import AutoLoad, { AutoloadPluginOptions } from "@fastify/autoload";
import { FastifyPluginAsync, FastifyServerOptions } from "fastify";
import Env from "@fastify/env";
import Cors from "@fastify/cors";
import Helmet from "@fastify/helmet";
import Auth0 from "@auth0/auth0-fastify-api";

Then, ensure that the new environment variables are loaded correctly. Your existing

@fastify/env
setup already loads the environment variables required to build the application. Now, you need to also include the
AUTH0_DOMAIN
and
AUTH0_AUDIENCE
values in its schema and validation.

Update the code right below the imports section to the following:

type Envs = {
  PORT: number;
  CORS_ALLOWED_ORIGINS: string;
  AUTH0_DOMAIN: string;
  AUTH0_AUDIENCE: string;
};

const envOptions = {
  schema: {
    type: "object",
    required: [
      "PORT",
      "CORS_ALLOWED_ORIGINS",
      "AUTH0_DOMAIN",
      "AUTH0_AUDIENCE",
    ],
    properties: {
      PORT: {
        type: "number",
      },
      CORS_ALLOWED_ORIGINS: {
        type: "string",
      },
      AUTH0_DOMAIN: {
        type: "string",
      },
      AUTH0_AUDIENCE: {
        type: "string",
      },
    },
  },
};

Next, within your main

app
plugin function, extract the
AUTH0_DOMAIN
and
AUTH0_AUDIENCE
environment variable values using the
fastify.getEnvs()
method:

const app: FastifyPluginAsync<AppOptions> = async (
  fastify,
  opts
): Promise<void> => {
  // Place here your custom code!

  await fastify.register(Env, envOptions);

  const { CORS_ALLOWED_ORIGINS, AUTH0_DOMAIN, AUTH0_AUDIENCE } =
    fastify.getEnvs<Envs>();

  // Other plugin registrations...

  // Do not touch the following lines...
};

Then, after loading the environment variables, registering other core plugins like

Cors
and
Helmet
, and registering the error handlers, you can register the
Auth0
plugin:

const app: FastifyPluginAsync<AppOptions> = async (
  fastify,
  opts
): Promise<void> => {
  // Place here your custom code!

  // Other plugin registrations and error handling...

  fastify.register(Auth0, {
    domain: AUTH0_DOMAIN,
    audience: AUTH0_AUDIENCE,
  });

  // Do not touch the following lines...
};

Registering this plugin decorates the

fastify
instance by attaching to it the
requireAuth()
function. Executing the
requireAuth()
function creates a
preHandler
hook you can use to protect routes.

Protect Fastify API Routes

With the Auth0 Fastify plugin registered, you can now secure your document-related API routes in the

src/routes/documents/index.ts
file that defines a Fastify router plugin.

There are two strategies you can use to restrict access to routes when using the Auth0 Fastify plugin that leverages Fastify's encapsulation model:

  1. Protect routes individually: You can apply the
    requireAuth()
    handler to each route that needs protection using the
    preHandler
    route option
    . A
    preHanlder
    is a hook that allows you to specify a function that Fastify executes before a route's handler.
  2. Protect routes at the plugin level: You can apply the
    requireAuth()
    handler once using the
    addHook()
    function to add a specific hook in the lifecycle of Fastify. In this case, you'd want to call
    fastify.addHook('preHandler', ...)
    within the
    documents
    plugin scope before its route definitions to automatically protect all routes defined within that plugin context after the hook is added.

Let's explore both strategies.

Preparing the Document Router

First, you'll need to do a bit of cleanup and preparation in the

src/routes/documents/index.ts
file: You need to remove the hardcoded, default owner ID as the actual user ID you need will come from the validated access token.

The Auth0 Fastify SDK exposes the claims extracted from the token as the

user
property on the
FastifyRequest
object, which will contain the subject (
sub
) claim, which represents the Auth0 ID. Anytime a user signs up for your application, Auth0 creates a user record on your tenant's user database, assigning them a unique identifier, which is shared with your application as the
sub
property in ID and access tokens.

With that in mind, update a route using the following pattern to define the

preHandler
function and use the
request.user.sub
as the value of the
ownerId
to query the documents table and make changes only to resources owner by the user who is logged in:

fastify.get(
  "/",
  { schema: jsonSchemas.getAllDocuments, preHandler: fastify.requireAuth() },
  async (request, reply) => {
    const ownerId = request.user.sub;

    if (!ownerId) {
      fastify.log.error(
        "Authentication successful, but user ID (sub) missing in token"
      );
      return reply
        .status(400)
        .send({ message: "Authentication context incomplete." });
    }

    const result = await fastify.documentService.findAll(ownerId);

    if (result.isErr()) {
      fastify.log.error(
        { error: result.error, ownerId },
        "Error fetching all documents for user"
      );

      return reply.status(500).send(result.error.message);
    }

    reply.status(200).send(result.value);
  }
);

The fully updated router plugin looks like this:

import { FastifyPluginAsync } from "fastify";
import {
  CreateDocumentInput,
  jsonSchemas,
  UpdateDocumentInput,
} from "./document.zod";
import { ServiceErrorCode } from "../../models/service-error.model";

interface DocumentParams {
  id: string;
}

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  // Add route definitions here
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments, preHandler: fastify.requireAuth() },
    async (request, reply) => {
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.findAll(ownerId);

      if (result.isErr()) {
        fastify.log.error(
          { error: result.error, ownerId },
          "Error fetching all documents for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument, preHandler: fastify.requireAuth() },
    async (request, reply) => {
      const id = request.params.id;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.find(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to find document for user"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.warn(
          { id, ownerId, error: result.error },
          "Error finding document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.post<{ Body: CreateDocumentInput }>(
    "/",
    { schema: jsonSchemas.createDocument, preHandler: fastify.requireAuth() },
    async (request, reply) => {
      const documentData = request.body;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.create(serviceInput);

      if (result.isErr()) {
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { error: result.error, body: serviceInput },
            "Conflict error creating a document for user"
          );
          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { error: result.error, body: serviceInput },
          "Error creating a document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(201).send(result.value);
    }
  );

  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
    "/:id",
    { schema: jsonSchemas.updateDocument, preHandler: fastify.requireAuth() },
    async (request, reply) => {
      const id = request.params.id;
      const documentData = request.body;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.update(id, serviceInput);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to update document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { id, error: result.error, body: serviceInput },
            "Conflict error updating document for user"
          );

          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error, body: serviceInput },
          "Error updating document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.delete<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.deleteDocument, preHandler: fastify.requireAuth() },
    async (request, reply) => {
      const id = request.params.id;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.remove(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to delete a document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error },
          "Error removing document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(204).send();
    }
  );
};

export default documents;

As you can see, this method is very clear about which routes are protected but can be verbose if many routes need the same protection.

As such, you can protect routes at the plugin level using

addHook
as follows if all routes require the same protection. It's an important condition to reiterate because you add the hook once, and it applies the same level of protection to all routes defined after the hook.

You'll router plugin would then look as follows:

import { FastifyPluginAsync } from "fastify";
import {
  CreateDocumentInput,
  jsonSchemas,
  UpdateDocumentInput,
} from "./document.zod";
import { ServiceErrorCode } from "../../models/service-error.model";

interface DocumentParams {
  id: string;
}

const documents: FastifyPluginAsync = async (fastify, opts): Promise<void> => {
  fastify.addHook("preHandler", fastify.requireAuth());

  // Add route definitions here
  fastify.get(
    "/",
    { schema: jsonSchemas.getAllDocuments },
    async (request, reply) => {
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.findAll(ownerId);

      if (result.isErr()) {
        fastify.log.error(
          { error: result.error, ownerId },
          "Error fetching all documents for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.get<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.getDocument },
    async (request, reply) => {
      const id = request.params.id;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.find(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to find document for user"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.warn(
          { id, ownerId, error: result.error },
          "Error finding document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.post<{ Body: CreateDocumentInput }>(
    "/",
    { schema: jsonSchemas.createDocument },
    async (request, reply) => {
      const documentData = request.body;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.create(serviceInput);

      if (result.isErr()) {
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { error: result.error, body: serviceInput },
            "Conflict error creating a document for user"
          );
          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { error: result.error, body: serviceInput },
          "Error creating a document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(201).send(result.value);
    }
  );

  fastify.put<{ Params: DocumentParams; Body: UpdateDocumentInput }>(
    "/:id",
    { schema: jsonSchemas.updateDocument },
    async (request, reply) => {
      const id = request.params.id;
      const documentData = request.body;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const serviceInput = {
        ...documentData,
        ownerId: ownerId,
      };

      const result = await fastify.documentService.update(id, serviceInput);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to update document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }
        // Handle potential conflict error from service
        if (result.error.code === ServiceErrorCode.Conflict) {
          fastify.log.warn(
            { id, error: result.error, body: serviceInput },
            "Conflict error updating document for user"
          );

          return reply.status(409).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error, body: serviceInput },
          "Error updating document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(200).send(result.value);
    }
  );

  fastify.delete<{ Params: DocumentParams }>(
    "/:id",
    { schema: jsonSchemas.deleteDocument },
    async (request, reply) => {
      const id = request.params.id;
      const ownerId = request.user.sub;

      if (!ownerId) {
        fastify.log.error(
          "Authentication successful, but user ID (sub) missing in token"
        );
        return reply
          .status(400)
          .send({ message: "Authentication context incomplete." });
      }

      const result = await fastify.documentService.remove(id, ownerId);

      if (result.isErr()) {
        if (result.error.code === ServiceErrorCode.NotFound) {
          fastify.log.warn(
            { id, ownerId, error: result.error },
            "Failed to delete a document (not found or wrong user)"
          );

          return reply.status(404).send(result.error.message);
        }

        fastify.log.error(
          { id, ownerId, error: result.error },
          "Error removing document for user"
        );

        return reply.status(500).send(result.error.message);
      }

      reply.status(204).send();
    }
  );
};

export default documents;

You only need to modify route handlers to use the

request.user.sub
as the value of the
ownerId
value.

This approach is a bit cleaner, reduces repetition, and offers less granular control within the plugin. This is often preferred if all document routes require the same basic authentication.

Check Permissions (Scopes) in Fastify

Beyond knowing who the user is, which is the authentication process, you often need to know what the user is allowed to do, which is the authorization process. In Auth0 terms, what a user can do is often handled using scopes that represent permissions and are included in the access token.

The Auth0 Fastify SDK supports checking scopes directly within the

requireAuth()
decorator function.

Imagine you defined a

write:documents
permission in your Auth0 API settings and configured Auth0 using Actions Rules or RBAC settings to include granted permissions in the
scope
or a custom claim within the access token.

You can check for required scopes like so:

// Example: POST route requiring 'write:documents' scope

fastify.post<{ Body: CreateDocumentInput }>(
  "/",
 {
    schema: jsonSchemas.createDocument,
    // Use requireAuth with the scopes option
    preHandler: fastify.requireAuth({ scopes: "write:documents" })],
 },
  async (request, reply) => {
    const ownerId = request.user?.sub;
    if (!ownerId) {
      /* ... handle missing ownerId ... */
 }

    // ... rest of handler logic ...
 }
);

If a valid token is presented without the required

write:documents
scope, the
requireAuth
handler will automatically reject the request with a
403 Forbidden
status and an
insufficient_scope
error code.

If you need to check for multiple required scopes, you can pass an array to the

scopes
property:
{ scopes: ['write:documents', 'read:profile'] }

Can you use the

addHook
strategy when different routes need different scopes?

As you may be thinking, that's not possible. Using something like

addHook('preHandler', fastify.requireAuth({ scopes: 'read:documents' }))
would force all routes in a plugin's context to require the
read:documents
scope.

Scopes let you define a more granular authorization strategy, which means that each Fastify API endpoint may need a different level of access control.

If your routes have varying scope requirements (GET needs

read:documents
, POST needs
write:documents
, and so on), you need to apply access control at the route level using the
preHandler
strategy and specifying the appropriate scopes for each.

The

addHook
approach shines when all routes within the plugin share the exact same authentication and scope requirements, including the case of requiring only authentication with no specific scopes.

Testing with Access Tokens

Now that routes are protected, requests need a valid Access Token from Auth0. Let's simulate making authenticated requests using

curl
and a test access token from Auth0.

Get an Auth0 access token

You can get a test access token from the Auth0 Dashboard by following these steps:

Head back to the Auth0 registration page of your Fastify API and click on the "Test" tab. If this is your first time setting up a testing application, click on the "Create & Authorize Test Application" button.

Locate the section called "Response" and click on the copy button in the top-right corner. Use the copied token in the

Authorization
header of the following test requests.

Making Authenticated Requests

GET
all documents:

curl -X GET http://localhost:8080/api/documents \
 -H "Authorization: Bearer ${ACCESS_TOKEN}" | jq .

POST
a new document:

curl -X POST http://localhost:8080/api/documents \
 -H "Authorization: Bearer ${ACCESS_TOKEN}" \
     -H "Content-Type: application/json" \
 -d '{"title": "My Secured Document", "content": "This requires auth!"}' | jq .

Test an unauthorized access:

curl -X GET http://localhost:8080/api/documents -v

Testing these different scenarios helps you confirm your security setup is working as intended.

What's Next

You've successfully secured your Fastify API using Auth0 and the

@auth0/auth0-fastify-api
SDK! You've learned how to:

  • Register your API with Auth0.
  • Configure your Fastify app with Auth0 credentials.
  • Integrate the Auth0 SDK as a Fastify plugin.
  • Protect routes using
    preHandler
    or
    addHook
    .
  • Implement scope-based authorization checks.
  • Test protected endpoints with access tokens.

As a next step, you could adapt this sample API to align with the Auth0 SPA demos from the Auth0 Developer Center to have a full-stack system where you can see end-to-end access control in action.

You can also explore setting up Auth0 Role-Based Access Control (RBAC) to add custom claims to the test access token and test granular access control to manage complex permission sets.

Happy coding!