Docs

Implement basic Role Based Access Control (RBAC) with metadata

To control which users can access certain parts of your application, you can leverage Clerk's roles feature. Although Clerk offers a roles feature as part of the feature set for organizations, not every app implements organizations. This guide will cover a workaround for setting up a basic Role Based Access Control (RBAC) system for products that don't use Clerk's organizations or roles.

This guide assumes that you are using Next.js App Router. The concepts here can be adapted to Next.js Pages Router and Remix.

Configure the session token

Clerk provides user metadata, which is a tool that can be leveraged to build flexible custom logic into your application. Metadata can be used to store information, and in this case, it can be used to store a user's role.

unsafeMetadata can be read and updated in the browser, and because the user could modify this metadata it should be treated as unsafe and validated by your application before trusting it. privateMetadata can not be read or modified in the browser. publicMetadata can be read by the browser and can only be updated server-side or in the Clerk Dashboard, making it the safest and best choice for this use case.

To build a basic RBAC system, first, you need to make publicMetadata available to the application directly from the session token. With publicMetadata attached directly to the user's session, a fetch and network request isn't required every time you need to access the data.

In the Clerk Dashboard, navigate to Sessions. In the Customize session token section, select Edit. In the modal that opens, enter the following JSON. If you have already customized your session token, you may need to merge this with what you currently have.

{
	"metadata": "{{user.public_metadata}}"
}
The Sessions page in the Clerk Dashboard with the 'Customize session token' modal opened. The modal has a text field with the JSON 'metadata': '{{user.public_metadata}}'.

Caution

The entire session token has a limit of 4kb of data. Keep this in mind when customizing the session and add data sparingly.

Provide a global TypeScript definition

In your application's root folder, add a types directory. Inside of the types directory, add a globals.d.ts file. This file will provide auto-complete, prevent TypeScript errors when working with the role, and control the roles that are allowed in the application. For this guide, only an admin and moderator role will be defined.

types/globals.d.ts
export { };

declare global {
  interface CustomJwtSessionClaims {
    metadata: {
      role?: "admin" | "moderator";
    };
  }
}

Set the admin role for your user

Later, you will add a basic admin tool to change the user's role, but for now, let's manually add a role to your own user account. In the Clerk Dashboard, navigate to Users and select your own user account. Scroll down to the Metadata section and next to the Public option, select Edit. Add the following JSON and select Save.

{
  "role": "admin"
}
The Users page in the Clerk Dashboard with the 'Edit public metadata' modal open. The modal has a text field with the JSON 'role': 'admin'.

Create an admin dashboard and protect it

Now that your user has the admin role, let's build an admin dashboard to help you easily change users' roles. In your app/ directory, create an admin/ folder. Within the admin/ folder, create a dashboard/ folder. In the dashboard/ folder, create a file named page.tsx. Copy the following code and paste it into the file.

You want the dashboard to only be available to users with the admin role. As you configured earlier, a user's role can be found in the metadata stored in the session token. You can access the session token's claims using Clerk's auth() hook.

app/admin/dashboard/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";

export default function AdminDashboard() {
  const { sessionClaims } = auth();

  // If the user does not have the admin role, redirect them to the home page
  if (sessionClaims?.metadata.role !== "admin") {
    redirect("/");
  }

  return (
    <>
      <h1>This is the admin dashboard</h1>
      <p>This page is restricted to users with the 'admin' role.</p>
    </>
  );
}

The /admin/dashboard route now requires the user to sign into the application. It also requires that the user have a publicMetadata of {"role": "admin" }.

Create a reusable function to check roles

Let's create a helper function to make checking roles easier. The first step is modifying globals.d.ts. Create a type for Roles so that the union type for the roles can be used in other places in the application. Then, modify the interface you previously added to use the new Roles type.

types/globals.d.ts
export { };

// Create a type for the roles
export type Roles = "admin" | "moderator";

declare global {
  interface CustomJwtSessionClaims {
    metadata: {
      role?: Roles
    };
  }
}

The next step is creating the helper function. Create the utils/ directory and inside, add the file roles.ts. In the roles.ts file, add the following code.

utils/roles.ts
import { Roles } from "@/types/globals"
import { auth } from "@clerk/nextjs/server"

export const checkRole = (role: Roles) => {
  const { sessionClaims } = auth()

  return sessionClaims?.metadata.role === role;
}

This checkRole() helper will accept a role using the Roles type and will return true if the user has that role, or false if the user does not.

Next, the admin dashboard can be refactored to use the checkRole() helper. Navigate back to the admin dashboard file. In the if() statement, remove the check that was used previously and replace it with the new checkRole() helper with "admin" as the argument.

app/admin/dashboard/page.tsx
import { redirect } from "next/navigation";
import { checkRole } from "@/utils/roles";

export default function AdminDashboard() {

  // If the user does not have the admin role, redirect them to the home page
  if (!checkRole("admin")) {
    redirect("/");
  }

  return (
    <>
      <h1>This is the admin dashboard</h1>
      <p>This page is restricted to users with the 'admin' role.</p>
    </>
  );
}

Note

You can modify the behavior of the helper function to meet your needs. Maybe it will return the roles that the user has, or you could create a protectByRole() and have that function handle the redirect.

Add admin tools to find users and add roles

You can leverage the checkRole() function you added along with server actions to build basic tools for finding users and managing roles.

Start with the server action. The setRole() action below will check the role of the current user using the checkRoles() helper to confirm that the user is an admin. It will then set the role for the selected user to the specified role.

app/admin/dashboard/_actions.ts
"use server";

import { checkRole } from "@/utils/roles";
import { clerkClient } from "@clerk/nextjs/server";

export async function setRole(formData: FormData) {
  // Check that the user trying to set the role is an admin
  if (!checkRole("admin")) {
    return { message: "Not Authorized" };
  }

  try {
    const res = await clerkClient.users.updateUser(
      formData.get("id") as string,
      {
        publicMetadata: { role: formData.get("role") },
      }
    );
    return { message: res.publicMetadata };
  } catch (err) {
    return { message: err };
  }
}

With the server action in place, you can build the <SearchUsers /> component. This will have a form that can be used to search for users. On form submission, the search term is added to the URL as a search parameter. The page component, which is a server component that you will refactor next, will perform a query based on this change.

app/admin/dashboard/_search-users.tsx
"use client";

import { usePathname, useRouter } from "next/navigation";

export const SearchUsers = () => {
  const router = useRouter();
  const pathname = usePathname();

  return (
    <div>
      <form
        onSubmit={async (e) => {
          e.preventDefault();
          const form = e.currentTarget;
          const formData = new FormData(form);
          const queryTerm = formData.get("search") as string;
          router.push(pathname + "?search=" + queryTerm);
        }}
      >
        <label htmlFor="search">Search for Users</label>
        <input id="search" name="search" type="text" />
        <button type="submit">Submit</button>
      </form>
    </div>
  );
};

With the server action and the search form in place, you'll refactor the server component for the app/admin/dashboard route. It will now check if a search parameter has been added to the URL by the search form, and if there is a search parameter present, it will search for users that match the entered term. If an array of one or more users is returned, then the component will render a list of users using their first and last name, primary email address, current role, and 'Make Admin' and 'Make Moderator' buttons. The buttons include hidden inputs for the user ID and the role, and they use the setRole() server action to update the role for the user.

src/app/admin/dashboard/page.tsx
import { redirect } from "next/navigation";
import { checkRole } from "@/utils/roles";
import { SearchUsers } from "./_search-users";
import { clerkClient } from "@clerk/nextjs/server";
import { setRole } from "./_actions";

export default async function AdminDashboard(params: {
  searchParams: { search?: string };
}) {
  if (!checkRole("admin")) {
    redirect("/");
  }

  const query = params.searchParams.search;

  const users = query ? await clerkClient.users.getUserList({ query }) : [];

  return (
    <>
      <h1>This is the admin dashboard</h1>
      <p>This page is restricted to users with the 'admin' role.</p>

      <SearchUsers />

      {users.map((user) => {
        return (
          <div key={user.id}>
            <div>
              {user.firstName} {user.lastName}
            </div>
            <div>
              {
                user.emailAddresses.find(
                  (email) => email.id === user.primaryEmailAddressId
                )?.emailAddress
              }
            </div>
            <div>{user.publicMetadata.role as string}</div>
            <div>
              <form action={setRole}>
                <input type="hidden" value={user.id} name="id" />
                <input type="hidden" value="admin" name="role" />
                <button type="submit">Make Admin</button>
              </form>
            </div>
            <div>
              <form action={setRole}>
                <input type="hidden" value={user.id} name="id" />
                <input type="hidden" value="moderator" name="role" />
                <button type="submit">Make Moderator</button>
              </form>
            </div>
          </div>
        );
      })}
    </>
  );
}

Finished 🎉

The building blocks needed for a custom RBAC system are in place. Roles are attached directly to the user's session, providing them to your application without needing a separate fetch and network request. The helper function is in place to check the user's role, reducing the code and simplifying the process. The final piece is an admin dashboard that allows admin's to find users and set their roles.

Feedback

What did you think of this content?