# Clerk

Source: https://permdock.com/docs/adapters/clerk

The permdock/clerk provider maps Clerk session claims, organization roles and permissions to a PermDock subject so PermDock conditions, snapshots and adapters run on top of Clerk authentication.

`permdock/clerk` is a provider. Clerk supplies the authenticated user, the active organization, its role and Clerk-defined permissions; PermDock supplies conditions, decisions, snapshots and adapters. The provider reads Clerk's session claims (server) or the `useAuth` / `useOrganization` state (client) and returns the subject PermDock expects.

## Purpose [#purpose]

Clerk's authorization primitives (`has()`, `auth.protect()`, `Protect`) check organization roles, custom permissions such as `org:invoices:create`, and billing features ([landscape](/docs/research/landscape)). They are boolean, string-keyed, tied to Clerk's organization model, and cannot express ownership or row conditions. PermDock keeps Clerk as the identity and role source and adds the permission model on top: typed references, `where` conditions, `approval-required`, agent adapters and data compilers. Where a team already defines Clerk permissions, the provider maps them to PermDock roles so nothing has to be defined twice.

## API [#api]

```ts
// src/permdock/server.ts
import { createPermDock } from "permdock/next";
import { auth } from "@clerk/nextjs/server";
import { subjectFromClerk } from "permdock/clerk";

export const { getPermDock, getPermission, PermDockProvider, permdockHandler } =
  createPermDock(policy, {
    subject: async () => subjectFromClerk(await auth()),
  });
// subject.principal = {
//   id: userId,
//   tenant: orgId,                                              // the active organization
//   roles: [],                                                  // global roles only through options.globalRoles
//   memberships: [{ tenant: orgId, roles: ['org:admin'] }],     // the active organization's membership
//   clerkPermissions: ['org:invoices:create'],
//   claims: { ...sessionClaims },
// }

// policy: Clerk organization roles are tenant-scoped roles
export const policy = definePolicy(permissions, {
  roles: [
    role(
      "org:admin",
      [allow(permissions.post.delete), allow(permissions.billing.plan.change)],
      { on: "tenant" },
    ),
    role(
      "org:member",
      [
        allow(permissions.post.read),
        allow(permissions.post.update, { where: { authorId: principal.id } }),
      ],
      { on: "tenant" },
    ),
  ],
  scopes: { tenant: { key: "orgId" } },
});
```

* `subjectFromClerk(authObject, options?)` accepts the result of `auth()` (Next.js), `getAuth(req)` (other frameworks) or a verified session token payload, and returns a principal whose `id` is `userId`, whose `tenant` is the active `orgId`, and whose `memberships` hold one entry for the active organization with `orgRole` as its role. `clerkPermissions` (custom permissions from the session claims) and selected `sessionClaims` sit next to them. No `subject` mapper is needed in `definePolicy`; the provider's output already is the principal shape ([tenancy](/docs/concepts/tenancy)).
* `options.memberships: 'all'` loads every organization the user belongs to through Clerk's Backend API (`users.getOrganizationMembershipList`) and adds one membership per organization, so `permdock.tenants()` and the tenant switcher have the full list and `permdock.tenant(id)` can preview another organization. The list is read in pages of 100 (Clerk's default page is 10), up to 5,000 memberships, and a row whose `publicUserData.userId` is another user's is dropped. A failed page drops the Backend API list and keeps the session membership. The default (`'active'`) reads only the session, which needs no API call.
* `createClerkSubjectResolver(options)` returns `(authObject) => Promise<Subject>` with the same options plus `cache: { ttl }` (a number of milliseconds, `'10s'` or `'500ms'`, at most 30 seconds). The resolver keeps each user's `memberships: 'all'` list for `ttl`, so a request inside the window makes no Backend API call, and a membership removed in Clerk still grants until the entry expires. A failed list is never cached. Create the resolver once per process; `subjectFromClerk` never caches.
* Both session token versions are read. Version 1 carries `org_id`, `org_role` and `org_permissions`. Version 2 carries the compact `o` claim (`id`, `rol`, `slg`, `per`, `fpm`): `rol` becomes `org:<rol>`, and each `org:<feature>:<permission>` key is rebuilt from the `o:` features in `fea` with the `fpm` bitmasks, the same way Clerk's SDK does. The `auth()` object is already decoded; its `orgId`, `orgRole` and `orgPermissions` win when present.
* `options.customRoles` accepts a `RoleSource` for Clerk custom roles: a Clerk role the policy did not declare (`org:billing_manager`) resolves to the assignable declared roles the source returns for that organization, and to nothing when it is unknown. Clerk role sets (roles restricted per plan) map to `RoleSource.assignable(tenant)`.
* `options.permissions` maps Clerk permission strings to PermDock grants when a team keeps Clerk permissions as the source: `{ 'org:invoices:create': permissions.billing.invoice.create }` produces an extra allow, scoped to the active organization, for subjects carrying that permission.
* `options.schema` (any Standard Schema, for example a Zod object) validates and types custom session claims; an invalid claim set yields a principal without `claims`, never a throw ([extension interfaces](/docs/concepts/extension-interfaces)).
* Client: no Clerk-specific hook is needed. The client uses the server-issued snapshot through `PermDockProvider`; `useTenant().switchTo(orgId)` calls Clerk's `setActive({ organization })` when the app wires it, then `invalidate()` fetches the snapshot for the new active organization ([UI](/docs/concepts/ui)).

Field mapping:

| Clerk auth object | PermDock subject |
| --- | --- |
| `userId` | `principal.id` |
| `orgId` | `principal.tenant` (active tenant) and the `tenant` of the session membership |
| `orgRole` | `memberships[0].roles`, a tenant-scoped role in the policy; global roles only through `options.globalRoles` (a claim path or a function over the verified claims) |
| `orgPermissions` | `clerkPermissions`, turned into tenant-scoped grants through `options.permissions` |
| Backend API organization memberships (`options.memberships: 'all'`) | One `{ tenant, roles }` membership per organization |
| `sessionClaims.*` | `claims.*` (custom claims configured in the Clerk session token template; typed through `options.schema`) |
| `sessionClaims.pla` | `o:` plans: `entitlements` on the session organization's membership; `u:` plans: `principal.plans` (prefix stripped) |
| `sessionClaims.fea` | `roles` entries through `options.features` (below): `o:` features on the session organization's membership, `u:` features on `principal.roles`; never grants on their own |

Clerk Billing puts the active plan (`pla`, for example `u:pro` or `o:enterprise`) and the enabled features (`fea`, for example `o:reporting,u:api_access`) in the session token, and Clerk's own `has({ feature })` and `has({ plan })` read them. A user plan lands on `principal.plans`; an organization plan lands on the session organization's membership as `entitlements`, so `to: plans.pro` matches it only while that organization is the active tenant. Features stay role fragments: `options.features: { reporting: 'reporting' }` adds the `reporting` role to subjects whose `fea` claim carries that feature, and the policy grants under `role('reporting', [...])`. A feature the plan does not include therefore has no grants, which is the same outcome Clerk's `has({ permission })` produces when a plan lacks the feature, expressed once in the policy instead of in every guard.

## Verified material [#verified-material]

`subjectFromClerk(auth)` consumes only what Clerk has verified; it does not read cookies, headers or tokens itself ([Authentication and PermDock](/docs/concepts/authentication)).

* **Input.** The object returned by `auth()` in Next.js or `getAuth(req)` in other frameworks, after `clerkMiddleware` has verified the session token's signature against Clerk's JWKS, or a session token payload the app verified with Clerk's backend SDK. Anything else (a decoded-but-unverified JWT, a value read from `localStorage`, a `userId` sent by the client) is not accepted: the type is Clerk's `AuthObject`, and a plain object shaped like one produces the anonymous subject with a development warning.
* **Fields used.** `userId` becomes `principal.id`; `orgId` becomes the active `tenant`; `orgRole` and `orgPermissions` become the membership for that organization; `sessionClaims` supplies the standard claims (`sub`, `exp`, `sid`) plus any custom claims. `sessionClaims.exp` becomes `subject.expiresAt`, and `sid` is carried on audit events so a session revocation can be traced.
* **Custom claims.** Values a policy condition needs beyond the organization role (a `plan`, a `region`) must come from Clerk's session token template, where they are populated by Clerk from user or organization metadata at token issuance. Only metadata written through the backend API (public and private metadata set server-side) should be projected into claims; metadata the client can edit (unsafe metadata) must not be, because `subjectFromClerk` cannot tell them apart once they are in the token.
* **Billing claims.** `pla` and `fea` are written by Clerk at token issuance from the subscription state and cannot be edited by the user, so they are eligible role sources. They describe the organisation's plan when the session has an active organisation (`o:` prefix) and the user's plan otherwise (`u:` prefix); `options.features` keys are matched against the unprefixed feature slug and the prefix is recorded on the subject so a policy can distinguish the two if it needs to. An organization's plan and features are bound to that organization: with `memberships: 'all'`, switching the active tenant to another organization leaves them behind, because the token only says what the session organization pays for.
* **Memberships.** The session carries only the active organization, so the default subject holds one membership. With `memberships: 'all'` the provider calls the Backend API with the server secret; the list is server-fetched material and eligible for grants. Organization ids, never organization names or slugs, are the `tenant` values. A user with no active organization has `tenant` undefined and no memberships: tenant-scoped roles contribute nothing, global roles still apply.
* **Signed-out and expired.** `userId` of `null`, a missing auth object or an expired session yields `principal: null`. Nothing throws; only anonymous grants apply.
* **MFA.** Clerk's session token carries `fva` (factor verification age): a pair of minutes since first-factor and second-factor verification. A second element of `-1` means no second factor is registered. `subjectFromClerk` does not invent RFC 8176 `amr`; map `fva[1] >= 0` in a wrapper when a grant uses `assurance({ amr: ['mfa'] })`.
* **Not read.** Clerk's client-side hooks (`useAuth`, `useOrganization`) are never a source for the server subject; they only trigger `invalidate()` on the client.

## Request lifecycle [#request-lifecycle]

1. Clerk middleware authenticates the request and populates `auth()` with `userId`, `orgId`, `orgRole`, `orgPermissions` and session claims.
2. `subjectFromClerk` maps these to the PermDock subject; the active organization defines `principal.tenant` and its membership.
3. `createPermDock` builds the request-scoped instance; `getPermDock()` / `getPermission()` and the snapshot resolver use it.
4. On organization switch or role change, Clerk emits a new session token; the app calls `updateTag` for the user (Clerk webhooks `organizationMembership.updated` are the server trigger) and the client `invalidate()` runs on `useOrganization` change. From those webhooks, emit `membershipEvent({ source: 'clerk', operation, principal: { id: data.public_user_id }, scope: 'tenant', id: data.organization.id, roles: { added, removed } })` to the same sink used for decisions. There is no Clerk package entry for this.

On Expo, Clerk's SDK feeds the same snapshot path through [React Native](/docs/adapters/react-native); there is no Clerk-specific persisted-snapshot helper.

### Membership events [#membership-events]

```ts
import { membershipEvent } from "permdock";

// inside organizationMembership.created | .updated | .deleted
await sink.write([
  membershipEvent({
    source: "clerk",
    operation: event.type.endsWith("deleted")
      ? "removed"
      : event.type.endsWith("created")
        ? "added"
        : "changed",
    principal: { id: data.public_user_id },
    scope: "tenant",
    id: data.organization.id,
    roles: { added: data.role ? [data.role] : [], removed: [] },
  }),
]);
```

## Sessions and devices [#sessions-and-devices]

List and revoke sessions through Clerk's Backend API (`Session` list / revoke). PermDock does not own a device list UI. Revocation reaches PermDock through Back-Channel Logout or CAEP into [`permdock/ssf`](/docs/adapters/ssf), joined on `subject.session` (`sid`). With `approvals` set on the SSF factory, pending approval requests for that session are cancelled.

## What it validates [#what-it-validates]

* Token verification is Clerk's; the provider only reads the already-verified auth object or a payload the app verified with Clerk's SDK.
* Role and permission strings from Clerk must match names declared in `definePolicy` or `options.permissions`; unknown strings are dropped with a development warning (fail closed).
* Custom session claims used in conditions (for example a `plan` claim) are exposed under `subject.claims`, validated by the `schema` option when one is passed, and typed through `SubjectOf`.
* Clerk permission strings become direct, tenant-scoped grants through `options.permissions`, not one PermDock role per permission.
* Nothing is read from the client-side Clerk state for authorization decisions.

## How denials surface [#how-denials-surface]

* Through PermDock, per the shared contract on [adapters](/docs/adapters); `assert` runs the Next.js `redirect()` handler when configured.
* Clerk's `Protect` and `has()` remain available for Clerk-native checks (billing features, plugin routes); apps are encouraged to use `Protected` from `permdock/react` for everything expressed as a PermDock permission so there is one denial path.
* Signed-out users yield the anonymous subject; only anonymous grants apply.
* A row belonging to another organization is `denied` with reason `tenant-mismatch`; a request for an organization the user is not a member of is `denied` with `no-membership` ([tenancy](/docs/concepts/tenancy)).

## Example app [#example-app]

`apps/examples/clerk`: a Hono server that boots with `pnpm start` on `127.0.0.1:3464`. `GET /health` is the liveness probe. `createPermDock` from `permdock/hono` resolves the subject with `createClerkSubjectResolver` over a fixed Clerk auth object (`org:member` plus `org:invoices:create`), `memberships: 'all'` and `cache: { ttl: '10s' }`. `protect` guards `PATCH /posts/:id` and `POST /posts/:id/delete`, so a denial is a Problem Details `403`. No Clerk secret and no Next.js app.

## Related standards [#related-standards]

* [Subject](/docs/concepts/subject): principal fields and roles.
* [Tenants, teams and scoped roles](/docs/concepts/tenancy): memberships, the active tenant, custom roles.
* [Next.js adapter](/docs/adapters/next): the factory this provider plugs into.
* [Snapshots](/docs/concepts/snapshots): client state and invalidation.
* [Landscape](/docs/research/landscape): Clerk authorization checks.
* [Tenancy](/docs/concepts/tenancy#why-this-model): Clerk organizations, custom roles and role sets.
