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.
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 } 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: principal.id } })],
{ on: "tenant" },
), // fragment merges by name
role("support", [allow(permissions.post.read)]), // global: admin plugin role
],
scopes: {
tenant: { key: "organizationId" },
team: { key: "teamId", within: "tenant" },
},
});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. Rows acustomSessionputs on the session (members,teamMembers) are used first. Otherwise it reads everymemberrow of the user in one query through the database adapter onauth.$context(findManyon themembermodel,userIdequal to the session user, at most 5,000 rows). Without that adapter, or when the query fails, it lists organization ids withauth.api.listOrganizationsand reads the caller's ownmemberrow in each one:getActiveMemberfor the active organization, and elsewherelistMembersfiltered withfilterField: 'userId'. A row whoseuserIdis not the session user is dropped, becauselistMembersreturns every member of an organization. A failed lookup drops that organization only. StockteamMemberrows carry no role, so team memberships fromlistUserTeamsappear only when a role is added to the row; otherwise put team roles on the session.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) parsesuser.additionalFieldsintoprincipal.claims. Without a schema no additional field reaches claims, because a user can edit their own additional fields unless the app setsinput: falseon each one. 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, and otheradditionalFieldsreachprincipal.claimsonly throughoptions.schema. - 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. - MFA. The two-factor plugin creates a session only after TOTP, OTP or a backup code verifies.
user.twoFactorEnabledsays the user enrolled; the plugin does not store RFC 8176amron the session.subjectFromBetterAuthdoes not invent one. MaptwoFactorEnabledplus a completed session in a wrapper when a grant usesassurance({ amr: ['mfa'] }).
Request lifecycle
- Better Auth authenticates the request and yields a session with
activeOrganizationId. - The provider loads the user's own
memberandteamMemberrows (never another member's), 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. Pass{ sink }to write amembershipevent (source: 'better-auth') withrole,previousRole,teamIdandby.
Sessions and devices
List and revoke sessions through Better Auth's session API (listSessions, revokeSession). PermDock does not own a device list UI. Revocation reaches PermDock through Back-Channel Logout or CAEP into permdock/ssf, joined on subject.session. With approvals set on the SSF factory, pending approval requests for that session are cancelled.
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.
rolesFromAccessControlseeds roles at runtime from the statements; there is no build step and no reverse mapping that emits Better Auth statements frompermissions.betterAuthRoleSourcegives a dynamicorganizationRolethe declared assignable roles whose statements it covers, matched by permission set;organizationRoleneeds no extra column.- A role name used both as a global
user.roleand as an organization role is one declared role with two scopes, whichpermdock doctorreports.
How denials surface
- Through PermDock, per the shared contract on adapters. Better Auth's own
hasPermissionis 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 Hono server on 127.0.0.1:3463. createPermDock from permdock/hono resolves the subject with subjectFromBetterAuth over a fixed session with one member row, and reads dynamic roles through betterAuthRoleSource. protect guards PATCH /posts/:id (granted) and POST /posts/:id/delete (a Problem Details 403). No Better Auth server and no database.
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.
- Landscape: Better Auth access control capabilities and limits.
- Tenancy: Better Auth organizations, teams and dynamic access control.
Last updated on
better-supabase
The permdock/better-supabase entry fills better-supabase's authorization slots with PermDock, an authorization provider for its SQL modules and doctor, bucket and topic policies, API keys, MCP tool hooks, a credential guard, and a subject from its session.
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.