# Better Auth

Source: https://permdock.com/docs/adapters/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 [#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](/docs/research/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 [#api]

```ts
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 with `id`, `tenant` (`activeOrganizationId`), `roles` (global roles from `user.role`, managed by the `admin` plugin) and `memberships`: one entry per `member` row the user holds (every organization by default, so the tenant switcher has the full list) with the row's `role` values, plus one entry per `teamMember` row with `via: 'team:<id>'`. Dynamic roles created through Better Auth's `dynamicAccessControl` (`organizationRole` rows) appear by name on the membership and resolve through the `RoleSource` below. The function is async because it reads member and team rows. Rows a `customSession` puts on the session (`members`, `teamMembers`) are used first. Otherwise it reads every `member` row of the user in one query through the database adapter on `auth.$context` (`findMany` on the `member` model, `userId` equal to the session user, at most 5,000 rows). Without that adapter, or when the query fails, it lists organization ids with `auth.api.listOrganizations` and reads the caller's own `member` row in each one: `getActiveMember` for the active organization, and elsewhere `listMembers` filtered with `filterField: 'userId'`. A row whose `userId` is not the session user is dropped, because `listMembers` returns every member of an organization. A failed lookup drops that organization only. Stock `teamMember` rows carry no role, so team memberships from `listUserTeams` appear 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 a `RoleSource` over the `organizationRole` table: `rolesFor(tenant)` returns each dynamic role as a `CustomRole` whose `includes` are the declared assignable roles the Better Auth role's statement matches, and `assignable(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 entry `resource: [action]` to `allow(permissions.<resource>.<action>)` where a matching PermDock permission exists, producing role fragments that merge with hand-written fragments of the same name. `options.on` sets the scope of the generated roles (`'tenant'` for `organization` plugin roles; omit for `admin` plugin 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) })` in `permdock/hono`.
* `options.schema` (any Standard Schema) parses `user.additionalFields` into `principal.claims`. Without a schema no additional field reaches claims, because a user can edit their own additional fields unless the app sets `input: false` on each one. An invalid record drops the extra fields, never the subject.

## Verified material [#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](/docs/concepts/authentication)).

* **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-side `useSession()` value or a raw cookie is not accepted; a `null` session yields the anonymous subject.
* **Fields used.** `session.user.id` becomes `principal.id`; `session.session.activeOrganizationId` becomes `principal.tenant`; `session.session.expiresAt` becomes `subject.expiresAt`; `session.session.id` is carried on audit events.
* **Roles and memberships.** Global roles come from `user.role` as managed by the `admin` plugin. Organization roles come from the `member` rows the `organization` plugin holds (one membership per organization), team roles from `teamMember` rows, and dynamic roles from `organizationRole` rows through `betterAuthRoleSource`. 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 other `additionalFields` reach `principal.claims` only through `options.schema`.
* **Cookie cache staleness.** When Better Auth's cookie cache is enabled, `getSession` may answer from the signed cookie without hitting the database until the cache `maxAge` elapses. During that window `user.role` and 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, call `getSession` with `disableCookieCache: true`, or pair the provider's `onRoleChange` hook with `updateTag` and 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/jwt`](/docs/adapters/jwt) against 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.twoFactorEnabled` says the user enrolled; the plugin does not store RFC 8176 `amr` on the session. `subjectFromBetterAuth` does not invent one. Map `twoFactorEnabled` plus a completed session in a wrapper when a grant uses `assurance({ amr: ['mfa'] })`.

## Request lifecycle [#request-lifecycle]

1. Better Auth authenticates the request and yields a session with `activeOrganizationId`.
2. The provider loads the user's own `member` and `teamMember` rows (never another member's), producing the subject with `tenant` and `memberships`.
3. `createPermDock(policy, subject, { customRoles })` builds the request-scoped instance; declared roles resolve to grants, dynamic role names resolve through the `RoleSource` for the active organization.
4. Checks run through whichever adapter the app uses; `snapshot()` carries roles and grants to the client so `usePermission` answers ownership checks offline.
5. Role changes in Better Auth (`updateMemberRole`, dynamic role edits) should trigger `updateTag` for the affected users; the provider exposes an `onRoleChange` helper that wraps Better Auth's hooks for this. Pass `{ sink }` to write a `membership` event (`source: 'better-auth'`) with `role`, `previousRole`, `teamId` and `by`.

## Sessions and devices [#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`](/docs/adapters/ssf), joined on `subject.session`. With `approvals` set on the SSF factory, pending approval requests for that session are cancelled.

## What it validates [#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 `definePolicy` or resolve through `betterAuthRoleSource`; names that do neither are dropped with a warning (fail closed).
* `rolesFromAccessControl` checks every `resource:action` pair against `listPermissions(permissions)` and reports pairs that have no PermDock permission, so the two catalogs stay aligned; `permdock usage` includes these findings.
* No condition data comes from Better Auth; conditions are evaluated on app data as in any other setup.
* `rolesFromAccessControl` seeds roles at runtime from the statements; there is no build step and no reverse mapping that emits Better Auth statements from `permissions`.
* `betterAuthRoleSource` gives a dynamic `organizationRole` the declared assignable roles whose statements it covers, matched by permission set; `organizationRole` needs no extra column.
* A role name used both as a global `user.role` and as an organization role is one declared role with two scopes, which `permdock doctor` reports.

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

* Through PermDock, per the shared contract on [adapters](/docs/adapters). Better Auth's own `hasPermission` is not called on the request path, so there is one denial format.
* Apps that keep calling `auth.api.hasPermission` for 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 `tenant` undefined; tenant-scoped grants are `denied` with `no-membership`, global roles still apply. A row from another organization is `denied` with `tenant-mismatch` ([tenancy](/docs/concepts/tenancy)).

## Example app [#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 [#related-standards]

* [Subject](/docs/concepts/subject): principal fields and roles.
* [Tenants, teams and scoped roles](/docs/concepts/tenancy): memberships, `RoleSource`, team roles.
* [Policies](/docs/concepts/policies): role fragments merged by name.
* [Snapshots](/docs/concepts/snapshots): what the client receives.
* [Landscape](/docs/research/landscape): Better Auth access control capabilities and limits.
* [Tenancy](/docs/concepts/tenancy#why-this-model): Better Auth organizations, teams and dynamic access control.
