createAccess builds Better Auth for you. When your app already calls betterAuth({...}) itself, add Access to it as
a plugin instead:
aventaraAccess()is the server plugin. It takes the samepolicies,principal,resources,audit,passwordsandregistryascreateAccess.accessOf(auth)gives the framework itsaccessoption from that Better Auth instance.aventaraAccessClient()is the client plugin. It addsauthClient.access.can.
All three come from @aventara/access/better-auth.
An app with organizations
This app's rules are its organizations': Better Auth's organization() plugin holds the memberships, and the
organization bridge (below) puts a member's role and organization into the token.
// src/org.policies.ts
import { definePolicies } from "@aventara/access";
import { model } from "./generated/aventara/discovery.artifact.js";
/** What every token carries beside `sub`: the ban, and the organization bridge's two claims. */
export const claims = {
banned: "boolean",
orgRole: "string",
org: "string",
} as const;
/** The principal: the user, with the session's active organization copied up for the rules. */
export type Principal = {
id: string;
org?: string;
claims: { banned?: boolean; orgRole?: string; org?: string };
};
export const orgPolicies = definePolicies(model)
.principal<Principal>()
.roles(() => ({
// The bridge's role: an admin of the session's active organization.
orgAdmin: { global: { claims: { orgRole: "admin" } } },
}))
.grants(({ grant, me }) => [
// Every grant is held to the principal's organization: a role from the bridge is
// that organization's, never a global one.
grant("signedIn", "read", "Campaign", { where: { organizationId: me.org } }),
grant("orgAdmin", ["create", "update", "delete"], "Campaign", {
where: { organizationId: me.org },
}),
])
.denies(({ deny }) => [deny("*", "*", { if: { claims: { banned: true } } })]);// src/auth.ts
import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
import { PrismaPg } from "@prisma/adapter-pg";
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { organization } from "better-auth/plugins/organization";
import { PrismaClient } from "./generated/prisma/client.js";
import { claims, orgPolicies } from "./org.policies.js";
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
});
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
user: {
additionalFields: {
banned: { type: "boolean", required: false, input: false },
},
},
plugins: [
organization({
// Invitations are accepted only by a verified email (GHSA-fmh4-wcc4-5jm3, below).
requireEmailVerificationOnInvitation: true,
// Who may create an organization, and so own it: here, nobody but the server.
allowUserToCreateOrganization: false,
}),
aventaraAccess({
policies: { rules: orgPolicies, claims },
// A member's role in the session's active organization, and that organization.
organization: {
claim: "orgRole",
organizationClaim: "org",
roles: [
["owner", "admin"],
["admin", "admin"],
["member", "member"],
],
},
// The organization claim copied onto the principal, where the rules read it.
principal: ({ sub, claims }) => ({
id: sub,
...(claims.org === undefined ? {} : { org: claims.org }),
claims,
}),
// Every sign-in and every decision: `true` for the `accessAudit` table.
audit: { sink: (event) => console.log(event.kind, event.userId) },
}),
],
});
/** The framework's `access` option: the policy engine and each request's `me`. */
export const access = accessOf(auth);What the plugin does
- It carries Better Auth's
jwt()inside it, soGET /api/auth/tokenandGET /api/auth/jwksare there. Its tokens carrysuband the declared claims only, under your base URL's origin as issuer and audience, forpolicies.tokenTtl. Do not addjwt()beside it. - It runs
createAccess's own checks on your Better Auth options when Better Auth starts it, andaccessOf(auth)runs them again when the framework is built. A refusal is the sameAccessErrorcreateAccessthrows, so your server fails at startup with the sentence. accessOf(auth)reads requests ascreateAccess'sstatedoes. Its engine closes Better Auth's tables and seals their secrets alike.accessOf(auth).$Infer.Meis the principal type, and$Infer.Claimsthe claims.auditlogs sign-ins, refused sign-ins, sign-outs and tokens from Better Auth's hooks, and each decision ofaccessOf(auth).policy.- Passwords are hashed with argon2id as under
createAccess(Passwords). - What else
createAccessadds around Better Auth is not here: profiles,access.handler, the standalone host, and the$accessverbs. You mountauth.handleras you already do. - Call
aventaraAccess()once for eachbetterAuth({...}). One plugin in two instances whose options differ is refused withACCESS_PLUGIN_DUPLICATE. So isaventaraAccess()insidecreateAccess({ betterAuth: { plugins } }).
The organization bridge
organization: { claim, organizationClaim, roles } puts a member's role in Better Auth's organization plugin into
one claim, and the organization into another. Both must be declared in policies.claims as "string".
- When a token is issued (and when
meis read from the session cookie), Access reads the membership of the session's active organization from Better Auth's store and maps its role throughroles, an ordered list of[Better Auth role, value]. A member with several roles gets the value of the first one listed. - No active organization, no membership, or a role
rolesdoes not name gives neither claim. - Removing a member, or changing their role, changes their next token.
- The bridge owns both claims: neither the user's field of the same name nor
policies.claimsOffills them.
An organization role is that organization's, but the rules' roles are global. A rule on the bridged role alone would let
an admin of one organization act on every organization's rows. Hold every grant on it to the organization: copy the
organization claim onto the principal, and give each grant where: { organizationId: me.org }, as above. A principal
without the claim matches no such grant.
The bridge refuses, with ACCESS_OPTIONS_INVALID, an organization plugin that lets a member be made otherwise than the
app decided:
requireEmailVerificationOnInvitationmust betrue. Better Auth's advisory GHSA-fmh4-wcc4-5jm3 lets a user whose email is not verified accept an invitation sent to that email without it. Better Auth has no fixed version, andnpm auditreports 1.7.14 as clean because the advisory database's entry is out of date.allowUserToCreateOrganizationmust befalse, or a function that decides who may.- With
dynamicAccessControlon,rolesmay map only the plugin's static roles.
Without organization() in your plugins, the bridge is refused with ACCESS_RESOURCE_MISSING.
The client plugin
aventaraAccessClient({ api }) adds authClient.access.can to Better Auth's client. It asks the framework, not Better
Auth: one POST {api}/_access/can for the whole page, one boolean per question.
// src/auth-client.ts
import { aventaraAccessClient } from "@aventara/access/better-auth";
import { createAuthClient } from "better-auth/client";
export const authClient = createAuthClient({
plugins: [aventaraAccessClient({ api: "/api" })],
});
/** One request for the page: may the user edit this campaign, and add notes? */
export const pageAnswers = async (campaignId: string) => {
const { data, error } = await authClient.access.can([
{ resource: "Campaign", action: "update", where: { id: campaignId } },
{ resource: "Note", action: "create" },
]);
if (error !== null) {
throw new Error(`can: ${error.status} ${error.code ?? error.statusText}`);
}
const [editCampaign = false, createNote = false] = data;
return { editCampaign, createNote };
};apiis the framework's API path or URL. A path is read against the auth client'sbaseURL, else the page's address.- The request goes through Better Auth's own fetch, so it carries the session cookie, and the answer is Better Auth's
{ data, error }. - A question with
whereasks about one row, by its unique where. A row the user cannot read answersfalse, exactly as a missing one does. canonly shapes the page. The real request is still checked.
The app as the hub
With registry: { enabled: true }, the app that runs Better Auth is the registry other services push their files to
and pull them from, with no Access server beside it (Namespaces, the registry and the CLI).
// src/hub.ts
import { definePolicies } from "@aventara/access";
import { accessOf, aventaraAccess } from "@aventara/access/better-auth";
import { betterAuth } from "better-auth";
import { Pool } from "pg";
import { model } from "./generated/aventara/discovery.artifact.js";
/** The hub's own rules, in its own namespace: it pushes them like any other service. */
const rules = definePolicies(model, { namespace: "campaign" }).grants(({ grant }) => [
grant("signedIn", "read", "Campaign"),
]);
export const auth = betterAuth({
database: new Pool({ connectionString: process.env.DATABASE_URL }),
emailAndPassword: { enabled: true },
plugins: [
aventaraAccess({
policies: { rules, claims: {} },
registry: { enabled: true },
// Off Better Auth's Prisma adapter, name the Resource of each of its tables, or
// null for one the rules' schema does not have: here, none of them.
resources: {
authTables: {
user: null,
session: null,
account: null,
verification: null,
jwks: null,
accessNamespace: null,
accessKey: null,
accessPolicyRevision: null,
accessGuardrailRevision: null,
accessHead: null,
accessPushLog: null,
accessResourceOwner: null,
accessBeacon: null,
},
},
}),
],
});
/** The framework's `access` option, and the registry's operator API as `access.registry`. */
export const access = accessOf(auth);- The registry's tables are the plugin's schema, so
npx auth@~1.7.12 migrate(orgeneratefor an ORM's schema) creates them beside Better Auth's. They are the tablescreateAccessuses, so the data moves with you if you switch. - Its routes are the plugin's endpoints under Better Auth's base path. A service reaches them only with its namespace's
key in
x-access-key. A session cookie or a token of the hub opens none of them. access.registryis the operator's API. No route opens it: run it in the app's own process, or withavaccess keys … --server src/hub.ts.- The hub's own rules never come from the registry: they are the
policiesyou pass in the app's process. - A push's key is checked before Better Auth reads its body, on any mount: no key or a wrong one is refused, and a
declared length over 2 MiB is answered
413, unread. - Every plugin you install runs in your app's process and sees every request, registry keys included: its
onRequestandhooksare not checked. One whose endpoint can match a registry route, by name, path or parameters (/:a/v1/key), is refused. Install only plugins you trust with the keys. - Better Auth's rate limit reads the client's address from
X-Forwarded-Forunlessadvanced.ipAddress.trustedProxiesnames your proxy, so a caller that sends its own header is counted afresh each time. Set the client's address on your mount, asclientAddresseddoes on the NestJS mount, and behind a proxy settrustedProxies. Run production withNODE_ENV=production: Better Auth limits rates only under it. - The hub's tokens are one org token: one trust domain. A token any service sees works at every service that checks the hub's key set until it expires. Where services must not trust each other's callers, use one audience per service.