Skip to content
GitHub Twitter

Role-Based Access Control (RBAC) with Clerk User Metadata

Introduction

Unlike Auth0, Clerk does not provide explicit RBAC. Instead, you can use the publicMetadata field to store custom user data. This is a great way to store user roles/claims, which can be verified on the API server. However, we need a way to add this data to the user when they sign up. Webhooks are a great way to do this.

Clerk webhooks allow users to notify third-party services when an event is triggered in the Clerk dashboard. In this case, we will subscribe to the user.created event to add publicMetadata to the created user, and have it ready for use during the authorization step on first login.

Set up Clerk Webhook on Dashboard

We access the webhooks page by navigating via left menu on the Clerk dashboard. We set up a new webhook endpoint with the form:

https://{YOUR_NEXT_BASE_URL}/api/webhook

This will be the endpoint that Clerk will send the webhook payload to. We will need to set up a new API route in our Next.js app to handle this request. We also set up the filter to only trigger on the user.created event. Before we leave the page, we need to copy the signing secret for the webhook and save it as as an environment variable. We will need this to verify the webhook payload to make sure it is coming from Clerk.

Set up Webhook Handler in Next app

We will create a new API route in our Next.js app to handle the incoming webhook.

Clerk uses Svix to send webhooks. Svix provides a Node SDK that we can use to verify the webhook payload. We will use the verify function to verify the payload.

// src/pages/api/webhook.ts
import type { NextApiRequest, NextApiResponse } from "next";
import type { WebhookRequiredHeaders } from "svix";
import { Webhook } from "svix";

type EmailAddress = {
  email_address: string;
};

// Put whatever types you wish to extract from Webhook here.
type ClerkWebhookPayload = {
  data: {
    id: string;
    email_addresses: EmailAddress[];
    profile_image_url: string;
    username: string;
  };
};

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const secret = process.env.CLERK_WEBHOOK_SIGNING_SECRET;

  // Parse headers and body from webhook request
  const headers: WebhookRequiredHeaders = {
    "svix-id": req.headers["svix-id"] as string,
    "svix-timestamp": req.headers["svix-timestamp"] as string,
    "svix-signature": req.headers["svix-signature"] as string,
  };

  const body = JSON.stringify(req.body);

  const wh = new Webhook(secret as string);

  // Throws on error, returns the verified content on success
  const payload = wh.verify(body, headers) as ClerkWebhookPayload;

  /// --- continues below

Now that we are sure that this request is coming from Clerk, we can extract the payload and use it to update the user metadata.

  const params = {
    publicMetadata: {
        role: "user",
    },
  };

  await clerkClient.users.updateUser(payload?.data.id, params);

  return res.status(200).json({
    res: `User metadata updated`,
  });
};

export default handler;

Now the created user has a publicMetadata field with the role set to user. We can now use this data to verify the user role in the API routes.