# Clerk roles: publicMetadata or your own table?

> publicMetadata is free and instant but vendor state you cannot join on. A local roles table joins and audits but must be kept in sync. Pick per what you need to query.

You need an admin flag. Clerk offers `publicMetadata`: a JSON blob on the user,
writable only from your backend, readable in the session token. Your database
offers a `role` column. Both work. The choice determines what you can build in
six months, so it is worth ten minutes now.

## Option A: `publicMetadata.role`

```ts
await clerk.users.updateUserMetadata(userId, {
  publicMetadata: { ...existing, role: "admin" },
});
```

Read it server-side from the session claim, so a check costs nothing:

```ts
const { sessionClaims } = await auth();
const role = parseRole(sessionClaims?.metadata?.role);
```

**What is good.** No table, no migration, no sync. Clerk's dashboard is a usable
admin UI on day one: you can promote someone from your phone. It travels in the
session token, so a role check needs no network call and no database.

**What hurts.**

- *You cannot query it.* "List every admin" is a paginated crawl of the Backend
  API, not a `where role = 'admin'`. Neither is "how many support agents signed
  in this week".
- *You cannot join on it.* Any report that combines roles with your own data
  does two round trips and joins in TypeScript.
- *It is vendor state.* Moving off Clerk means exporting and re-homing it.
- *Propagation is not instant.* The claim is minted with the session. A demotion
  applies on the user's next token, unless you also revoke their sessions.
- *Merge hazard.* `updateUserMetadata` replaces the object you pass. A bare
  `{ role }` deletes every other key. Spread the existing metadata, always.

## Option B: a `role` column in your database

```sql
alter table users add column role text not null default 'user';
```

In a repo generated with this battery and an ORM, that column is already there:
the user mirror carries `role`, and the webhook keeps it in step with
`publicMetadata` on every `user.updated`. The choice below is therefore about
which one you *read*, not about which one exists.

**What is good.** It joins. It is queryable, indexable, and reportable. It is
yours: no export needed, no vendor coupling in your authorisation model. You
can add `granted_by` and `granted_at` beside it and have an audit trail. Changes
apply on the next request, with no token to re-mint.

**What hurts.**

- Every request that needs a role does a database read, usually fine, and
  usually already happening for other reasons, but it is not free.
- It only exists for users your webhook has already mirrored. A brand-new
  signup can reach your app a beat before the `user.created` delivery lands.
- The Clerk dashboard no longer tells the truth about roles, so you need a
  minimal internal UI or a documented SQL snippet.

## The pragmatic answer: both, with one direction

Write the role to `publicMetadata`. Mirror it into your table through the
`user.updated` webhook. Then:

- **Authorisation reads the claim**: free, on every request.
- **Queries and reports read the column**: joinable, indexable.
- **Writes go to Clerk only.** One writer. Never write the column directly, or
  the two disagree and you will spend an afternoon working out which is right.

That last rule is the whole design. A mirror with one writer is a cache. A
mirror with two writers is a bug that surfaces at the worst time.

Where the two disagree, the vendor wins: re-run the sync rather than patching
the row.

## When to skip publicMetadata entirely

Go database-only when:

- Roles are per-workspace rather than per-user (`user_id, org_id, role` is a
  table, not a scalar), and you are not using Clerk Organizations.
- Roles change often enough that token propagation delay is a support problem.
- You need an audit trail of who granted what, when.
- You expect to change auth provider and want authorisation untouched by that
  migration.

Go metadata-only when the app has exactly two kinds of people, the admin count
is in single digits, and nothing reports on roles. Do not build a table for
that. Adding it later is one migration plus a backfill from the Backend API.

## Clerk Organizations is a third thing

If your product is team-shaped, Clerk's Organizations already model membership
and per-org roles, and `auth()` returns `orgId` and `orgRole`. That is a better
fit than either option above for "is this user an admin *of this workspace*",
and it is a different question from "is this user a staff member of ours".

Do not model your internal staff roles as an organisation. Keep the two
vocabularies separate; conflating them is how a customer's org admin ends up
passing an internal admin check.

## The claim is free; the email address is not

Whichever store you pick, notice what the session token can and cannot answer.
It carries the user id, the org id and your custom claims. It does not carry the
email address or the display name: those live on the Backend `User`, one
`GET /v1/users/{id}` away.

That shapes the two helpers in `src/lib/auth/session.ts`:

```ts
// zero network calls: id, role, orgId, straight out of the token
const claims = await getSessionClaims();

// one Backend API call: adds email, name, imageUrl
const user = await getSessionUser();
```

`getSessionUser()` is the shared `@/lib/auth/session` surface every auth
battery implements, and the contract says it returns `email` and `name`. So it
pays for the call rather than handing back a narrower object: a
`Pick<SessionUser, "id" | "role" | "orgId">` here compiles fine in isolation and
breaks billing, the admin panel and the support widget the moment they read
`user.email`.

Two things keep the cost honest:

- It is wrapped in React `cache`, so a layout, three server components and a
  server action in one render share a single lookup. Clerk also memoises the
  underlying `fetch`, so the round trip happens once even across helpers.
- `getSessionClaims()` stays available for the checks that genuinely only need
  `id` and `role`. An admin gate on a route handler has no reason to fetch a
  display name it will never render.

Rule of thumb: **guard with claims, render with the user.** Reach for
`requireApiClaims("admin")` in a handler that authorises and then works with
ids; reach for `requireRole("admin")` in a page that also greets the person.

## Whichever you choose

- One helper reads the role. `parseRole` collapses anything unrecognised to the
  least privileged value, so a typo or a stale claim denies rather than grants.
- The role vocabulary lives in one file, ranked, so "support or above" is one
  call rather than a growing OR list.
- Users never write their own role. Not at sign-up, not through
  `unsafeMetadata`, not through a route that takes a role from the body.
- The check runs on the server on every privileged request. The client copy of
  the role decides what is rendered, and nothing else.

## Checking your work

- Promote a test user, sign out and in, and confirm the gated route opens.
- Demote them, revoke sessions, and confirm the route closes immediately.
- Set `publicMetadata.role` to `"wizard"`: everything denies, nothing crashes.
- Write an unrelated metadata key, then change the role, and confirm the first
  key survived.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
