Skip to content

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.

Clerk6 min readships at docs/solutions/clerk/roles-publicmetadata-vs-local-table.md

Tags: clerk · roles · authorization · publicmetadata · database

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

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

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

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

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:

// 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.