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
Clerk's authorization primitives (has(), auth.protect(), Protect) check organization roles, custom permissions such as org:invoices:create, and billing features (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
// 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 ofauth()(Next.js),getAuth(req)(other frameworks) or a verified session token payload, and returns a principal whoseidisuserId, whosetenantis the activeorgId, and whosemembershipshold one entry for the active organization withorgRoleas its role.clerkPermissions(custom permissions from the session claims) and selectedsessionClaimssit next to them. Nosubjectmapper is needed indefinePolicy; the provider's output already is the principal shape (tenancy).options.memberships: 'all'loads every organization the user belongs to through Clerk's Backend API (users.getOrganizationMembershipList) and adds one membership per organization, sopermdock.tenants()and the tenant switcher have the full list andpermdock.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 whosepublicUserData.userIdis 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 pluscache: { ttl }(a number of milliseconds,'10s'or'500ms', at most 30 seconds). The resolver keeps each user'smemberships: 'all'list forttl, 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;subjectFromClerknever caches.- Both session token versions are read. Version 1 carries
org_id,org_roleandorg_permissions. Version 2 carries the compactoclaim (id,rol,slg,per,fpm):rolbecomesorg:<rol>, and eachorg:<feature>:<permission>key is rebuilt from theo:features infeawith thefpmbitmasks, the same way Clerk's SDK does. Theauth()object is already decoded; itsorgId,orgRoleandorgPermissionswin when present. options.customRolesaccepts aRoleSourcefor 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 toRoleSource.assignable(tenant).options.permissionsmaps 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 withoutclaims, never a throw (extension interfaces).- Client: no Clerk-specific hook is needed. The client uses the server-issued snapshot through
PermDockProvider;useTenant().switchTo(orgId)calls Clerk'ssetActive({ organization })when the app wires it, theninvalidate()fetches the snapshot for the new active organization (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
subjectFromClerk(auth) consumes only what Clerk has verified; it does not read cookies, headers or tokens itself (Authentication and PermDock).
- Input. The object returned by
auth()in Next.js orgetAuth(req)in other frameworks, afterclerkMiddlewarehas 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 fromlocalStorage, auserIdsent by the client) is not accepted: the type is Clerk'sAuthObject, and a plain object shaped like one produces the anonymous subject with a development warning. - Fields used.
userIdbecomesprincipal.id;orgIdbecomes the activetenant;orgRoleandorgPermissionsbecome the membership for that organization;sessionClaimssupplies the standard claims (sub,exp,sid) plus any custom claims.sessionClaims.expbecomessubject.expiresAt, andsidis carried on audit events so a session revocation can be traced. - Custom claims. Values a policy condition needs beyond the organization role (a
plan, aregion) 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, becausesubjectFromClerkcannot tell them apart once they are in the token. - Billing claims.
plaandfeaare 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.featureskeys 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: withmemberships: '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 thetenantvalues. A user with no active organization hastenantundefined and no memberships: tenant-scoped roles contribute nothing, global roles still apply. - Signed-out and expired.
userIdofnull, a missing auth object or an expired session yieldsprincipal: 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-1means no second factor is registered.subjectFromClerkdoes not invent RFC 8176amr; mapfva[1] >= 0in a wrapper when a grant usesassurance({ amr: ['mfa'] }). - Not read. Clerk's client-side hooks (
useAuth,useOrganization) are never a source for the server subject; they only triggerinvalidate()on the client.
Request lifecycle
- Clerk middleware authenticates the request and populates
auth()withuserId,orgId,orgRole,orgPermissionsand session claims. subjectFromClerkmaps these to the PermDock subject; the active organization definesprincipal.tenantand its membership.createPermDockbuilds the request-scoped instance;getPermDock()/getPermission()and the snapshot resolver use it.- On organization switch or role change, Clerk emits a new session token; the app calls
updateTagfor the user (Clerk webhooksorganizationMembership.updatedare the server trigger) and the clientinvalidate()runs onuseOrganizationchange. From those webhooks, emitmembershipEvent({ 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; there is no Clerk-specific persisted-snapshot helper.
Membership events
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
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, joined on subject.session (sid). With approvals set on the SSF factory, pending approval requests for that session are cancelled.
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
definePolicyoroptions.permissions; unknown strings are dropped with a development warning (fail closed). - Custom session claims used in conditions (for example a
planclaim) are exposed undersubject.claims, validated by theschemaoption when one is passed, and typed throughSubjectOf. - 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
- Through PermDock, per the shared contract on adapters;
assertruns the Next.jsredirect()handler when configured. - Clerk's
Protectandhas()remain available for Clerk-native checks (billing features, plugin routes); apps are encouraged to useProtectedfrompermdock/reactfor 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
deniedwith reasontenant-mismatch; a request for an organization the user is not a member of isdeniedwithno-membership(tenancy).
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
- Subject: principal fields and roles.
- Tenants, teams and scoped roles: memberships, the active tenant, custom roles.
- Next.js adapter: the factory this provider plugs into.
- Snapshots: client state and invalidation.
- Landscape: Clerk authorization checks.
- Tenancy: Clerk organizations, custom roles and role sets.
Last updated on
Better Auth
The permdock/better-auth provider builds a PermDock subject from Better Auth sessions and organization roles, including dynamic database roles, so PermDock's conditions, snapshots and adapters layer on top of Better Auth access control.
Convex
The permdock/convex provider builds a PermDock subject from ctx.auth inside Convex queries, mutations and actions, and ships the snapshot to the client through a Convex query.