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.
Status: planned Phase: 4
permdock/better-auth is a provider. Better Auth already answers "which roles does this user hold in this organization"; PermDock answers "may this subject perform this action on this resource", with conditions, three-outcome decisions, snapshots for the client, and every server, agent and data adapter. The provider connects the two by turning a Better Auth session into a PermDock subject and, optionally, by deriving PermDock role fragments from Better Auth createAccessControl statements.
Purpose
Better Auth's access control (createAccessControl(statement), ac.newRole(...), hasPermission, dynamic roles stored in the database) is RBAC bound to the organization and admin plugins: statements are resource: [actions] records, checks are boolean, there are no conditions, no explain, no client snapshot with conditions, and no MCP or data-layer story (landscape). Teams on Better Auth who need ownership rules or agent tooling end up with a second permission system. The provider avoids that by making Better Auth the source of identity and role membership while PermDock owns the permission model.
API
import { createPermDock, definePolicy, role, allow, subject } from 'permdock'
import { subjectFromBetterAuth, betterAuthRoleSource, rolesFromAccessControl } from 'permdock/better-auth'
import { auth } from './auth' // betterAuth({ plugins: [organization({ ac, roles })] })
import { ac, owner, admin, member } from './permissions.better-auth'
// 1. Subject: session + active organization + memberships (organization roles, team roles, dynamic roles)
const session = await auth.api.getSession({ headers })
const permdock = await createPermDock(policy, await subjectFromBetterAuth(auth, session), {
customRoles: betterAuthRoleSource(auth), // organizationRole rows -> RoleSource
})
// subject.principal = {
// id,
// tenant: activeOrganizationId,
// roles: ['support'], // user.role from the admin plugin: global
// memberships: [
// { tenant: 'o_acme', roles: ['member', 'billing-admin'] }, // member.role; 'billing-admin' is dynamic
// { tenant: 'o_acme', team: 't_design', roles: ['lead'], via: 'team:t_design' }, // teamMember rows
// ],
// email,
// }
// 2. Optional: seed PermDock roles from Better Auth statements, then add what Better Auth cannot express
const seeded = rolesFromAccessControl({ ac, roles: { owner, admin, member } }, permissions, { on: 'tenant' })
export const policy = definePolicy(permissions, {
roles: [
...seeded, // 'member' gets allow(post.read), allow(post.create), ... scoped to the organization
role('member', [allow(permissions.post.update, { where: { authorId: subject.id } })], { on: 'tenant' }), // fragment merges by name
role('support', [allow(permissions.post.read)]), // global: admin plugin role
],
scopes: { tenant: { key: 'organizationId' }, team: { key: 'teamId' } },
})subjectFromBetterAuth(auth, session, options?)returns a principal withid,tenant(activeOrganizationId),roles(global roles fromuser.role, managed by theadminplugin) andmemberships: one entry permemberrow the user holds (every organization by default, so the tenant switcher has the full list) with the row'srolevalues, plus one entry perteamMemberrow withvia: 'team:<id>'. Dynamic roles created through Better Auth'sdynamicAccessControl(organizationRolerows) appear by name on the membership and resolve through theRoleSourcebelow. The function is async because it reads member and team rows.options.memberships: 'active'limits the read to the active organization.betterAuthRoleSource(auth)is aRoleSourceover theorganizationRoletable:rolesFor(tenant)returns each dynamic role as aCustomRolewhoseincludesare the declared assignable roles the Better Auth role's statement matches, andassignable(tenant)returns the declared assignable roles the organization may hand out. A dynamic role whose statement matches no declared assignable role resolves to nothing.rolesFromAccessControl(config, permissions, options?)maps each Better Auth statement entryresource: [action]toallow(permissions.<resource>.<action>)where a matching PermDock permission exists, producing role fragments that merge with hand-written fragments of the same name.options.onsets the scope of the generated roles ('tenant'fororganizationplugin roles; omit foradminplugin roles). Unmatched entries are reported, never silently dropped.- Server adapters accept the provider directly:
createPermDock(policy, { subject: (c) => subjectFromBetterAuth(auth, c.get('session')), customRoles: betterAuthRoleSource(auth) })inpermdock/hono. options.schema(any Standard Schema) validates and typesuser.additionalFieldsbefore they reachprincipal; an invalid record drops the extra fields, never the subject.
Verified material
subjectFromBetterAuth(auth, session) consumes the session Better Auth's server API returned; it never reads the cookie or the session token itself (Authentication and PermDock).
- Input. The result of
auth.api.getSession({ headers })on the server, which Better Auth has looked up (or decoded from the signed cookie cache) and validated for expiry. A session object assembled by the app, a client-sideuseSession()value or a raw cookie is not accepted; anullsession yields the anonymous subject. - Fields used.
session.user.idbecomesprincipal.id;session.session.activeOrganizationIdbecomesprincipal.tenant;session.session.expiresAtbecomessubject.expiresAt;session.session.idis carried on audit events. - Roles and memberships. Global roles come from
user.roleas managed by theadminplugin. Organization roles come from thememberrows theorganizationplugin holds (one membership per organization), team roles fromteamMemberrows, and dynamic roles fromorganizationRolerows throughbetterAuthRoleSource. All are server-managed through Better Auth's server API; a user cannot edit them through the client API. Ids (organizationId,teamId) are the membership keys, never slugs or names. Profile fields the user can change (name,image) are never used for grants. - Cookie cache staleness. When Better Auth's cookie cache is enabled,
getSessionmay answer from the signed cookie without hitting the database until the cachemaxAgeelapses. During that windowuser.roleand the member record reflect the state at cache time: a demoted admin keeps the admin role until the cookie refreshes. If role changes must apply on the next request, disable the cookie cache for routes that use PermDock, callgetSessionwithdisableCookieCache: true, or pair the provider'sonRoleChangehook withupdateTagand a session refresh. - No token parsing. Better Auth's bearer and JWT plugins produce tokens for other consumers; if an API receives one of those, verify it with Better Auth's own endpoint or with
permdock/jwtagainst the plugin's JWKS, then pass the verified result on. The provider does not verify tokens.
Request lifecycle
- Better Auth authenticates the request and yields a session with
activeOrganizationId. - The provider loads the user's
memberandteamMemberrows, producing the subject withtenantandmemberships. createPermDock(policy, subject, { customRoles })builds the request-scoped instance; declared roles resolve to grants, dynamic role names resolve through theRoleSourcefor the active organization.- Checks run through whichever adapter the app uses;
snapshot()carries roles and grants to the client sousePermissionanswers ownership checks offline. - Role changes in Better Auth (
updateMemberRole, dynamic role edits) should triggerupdateTagfor the affected users; the provider exposes anonRoleChangehelper that wraps Better Auth's hooks for this.
What it validates
- Session validity is Better Auth's responsibility; the provider never reads cookies or tokens itself.
- Role names from Better Auth must exist in
definePolicyor resolve throughbetterAuthRoleSource; names that do neither are dropped with a warning (fail closed). rolesFromAccessControlchecks everyresource:actionpair againstlistPermissions(permissions)and reports pairs that have no PermDock permission, so the two catalogs stay aligned;permdock usageincludes these findings.- No condition data comes from Better Auth; conditions are evaluated on app data as in any other setup.
How denials surface
- Through PermDock:
Decisionwith denials and alternatives, RFC 9457403from HTTP adapters, MCP refusals, AI SDKdenied. Better Auth's ownhasPermissionis not called on the request path, so there is one denial format. - Apps that keep calling
auth.api.hasPermissionfor Better Auth plugin routes (organization management) continue to get Better Auth's boolean; the provider does not intercept those. - A user with no active organization yields a subject with
tenantundefined; tenant-scoped grants aredeniedwithno-membership, global roles still apply. A row from another organization isdeniedwithtenant-mismatch(tenancy).
Example app
apps/examples/better-auth: a Next.js app with the Better Auth organization plugin, createAccessControl statements for post, seeded PermDock roles plus an ownership fragment, usePermission in the UI, and tests showing that a Better Auth role change is reflected in the next snapshot.
Related standards
- Subject: principal fields and roles.
- Tenants, teams and scoped roles: memberships,
RoleSource, team roles. - Policies: role fragments merged by name.
- Snapshots: what the client receives.
- Research: landscape: Better Auth access control capabilities and limits.
- Research: SaaS tenancy and roles: Better Auth organizations, teams and dynamic access control.
Open questions
- The plan names the provider's role (layer over
createAccessControl/newRole/hasPermissionand dynamic DB roles);subjectFromBetterAuth,betterAuthRoleSourceandrolesFromAccessControlare proposed export names. - Whether seeding from Better Auth statements should be a runtime call or a
permdock collectstep that writes a role fragment file. - Global
adminplugin roles next to organization roles: resolved by ADR 0024.user.rolefillsprincipal.roles(global); organization roles fillmemberships; a name used in both places is one declared role with two scopes, whichpermdock doctorreports. - Whether PermDock should offer the reverse mapping, emitting Better Auth statements from
permissionsfor teams that must keep Better Auth checks on plugin routes. - Whether
betterAuthRoleSourceshould match dynamic role statements to declared assignable roles by permission set (current) or require an explicitincludescolumn onorganizationRole.
Supabase
The permdock/supabase provider maps Supabase JWT claims to a PermDock subject, scaffolds the authorize() RBAC tables and hook, and compiles named permissions to authorize('perm') calls in RLS policies.
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.