Identity & integration

createAccess

Better Auth with Access beside it: the options, claims, principal, profiles, tokens and sessions.

Read as Markdown

createAccess builds Better Auth with Access beside it. It opens no port and mounts nothing (Hosts does that). Better Auth's options sit in the betterAuth block exactly as betterAuth({...}) takes them, so you can paste them from an existing Better Auth setup. Access's own concerns sit in their own blocks beside it.

Code
// src/access.ts
import { createAccess } from "@aventara/access/auth";
import { PrismaPg } from "@prisma/adapter-pg";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { policies } from "./access.policies.js";
import { PrismaClient } from "./generated/prisma/client.js";

/** Better Auth's own Prisma client: it reads and writes its tables through it. */
const prisma = new PrismaClient({
	adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL ?? "" }),
});

/** What every token carries beside `sub`. */
const claims = { role: "string", banned: "boolean" } as const;

export const access = createAccess({
	// Better Auth's options, as betterAuth({...}) takes them.
	betterAuth: {
		database: prismaAdapter(prisma, { provider: "postgresql" }),
		emailAndPassword: { enabled: true },
		user: {
			additionalFields: {
				role: { type: "string", required: false, input: false },
				banned: { type: "boolean", required: false, input: false },
			},
		},
	},
	// The rules, and the claims they read. Each claim is read from the user's field
	// of the same name.
	policies: { rules: policies, claims },
	// The rules are typed with your own Principal, so map the token to it.
	principal: ({ sub, claims }) => ({
		id: sub,
		claims: {
			...(claims.role === "admin" ? { role: "admin" as const } : {}),
			...(claims.banned === undefined ? {} : { banned: claims.banned }),
		},
	}),
	// The rules read User, one of Better Auth's tables: open it explicitly.
	resources: { users: true },
});

export default access;

src/access.policies.ts is the file from Rules.

The base URL

Better Auth reads BETTER_AUTH_SECRET and BETTER_AUTH_URL from the environment, or takes secret and baseURL in the block. Access needs a base URL it can fix when the instance is built. Its tokens' issuer and audience are that URL's origin: baseURL, else BETTER_AUTH_URL, or a dynamic baseURL's fallback. With none, createAccess refuses with ACCESS_OPTIONS_INVALID.

The issuer is the origin alone, not the path. Two apps on one origin that share a database (and so Better Auth's key set) accept each other's tokens. Give each app its own origin, or its own database.

The levers

OptionTakesDefault
betterAuthBetter Auth's optionsrequired
policies.rulesThe builder, its .contract(), or a pulled policies file as JSONrequired, except on a registry-only server
policies.claimsThe claims a token carries beside sub, by type: { role: "string", banned: "boolean" }required with rules
policies.claimsOf({ user, session }) => claims, when the claims are not the user's fields of the same nameeach claim from the user's field
policies.guardrailsThe org's guardrails, as pulled (Guardrails)none
policies.lockThe project's avAccess.lock.json (The lock at startup)none
policies.requireLocktrue: refuse to start without policies.lock. Recommended in production.false
policies.tokenTtlHow long a token lives, in whole seconds from 300 to 900900
principal(identity) => principal | null: maps { sub, claims } to your principalme is the identity
profile{ kind: "web" }, { kind: "service" }, or the kiosk profile (The kiosk profile)web
registry{ enabled: true } turns on the registry (Namespaces, the registry and the CLI){ enabled: false }
registry.ownership"first-push" or "assigned""first-push"
audittrue, false or { sink } (The audit log)off
resources{ users?, members?, authTables? } (Better Auth's tables as Resources)both off
passwords{ argon2id, legacyScrypt? } (Passwords)"owasp-minimum"
mcp{ resource, openRegistration? } (Agents over MCP)none
audienceThis service's URL, for tokens addressed to it alone (One audience per service)none

An option createAccess does not take, or a block it cannot read, is refused with ACCESS_OPTIONS_INVALID and a sentence that says what it takes.

Claims

Each declared claim is read from the user's field of the same name. That field needs input: false, as role and banned have above. A field without it is one the user sets themselves, at sign-up or with /update-user. createAccess refuses such a field (ACCESS_OPTIONS_INVALID), for fields a plugin adds too.

  • To read a claim from somewhere else, return it from policies.claimsOf. It must return every declared boolean claim, as banned: user.banned === true. An answer without one is a type error (AccessClaimsAnswer), and at run time it is refused and no token is issued.
  • null or undefined for a boolean claim is false. A token carries every declared boolean claim.
  • Read claims in claimsOf only from fields your server alone writes. Access does not check what it returns beyond the boolean claims.
  • A claim the rules read that policies.claims does not declare is a type error, and is refused at construction (ACCESS_CLAIMS_UNDECLARED).
  • tokenTtl bounds how long a changed claim takes to reach every service. A user you ban gets the new claim with their next token.

The principal

principal is required when the rules are typed with your own principal, and optional when they are typed with AccessIdentity<typeof claims>.

  • Return null to leave the caller anonymous.
  • Carry the token's claims as they are. A boolean claim the principal drops is unknown to the rules, so a deny such as banned: true refuses it.
  • Return a new object for each user (one cached per user is fine). An object principal already returned for another user is refused with ACCESS_OPTIONS_INVALID: the audit log names the user each principal stands for. Only the object principal returns is checked; a Map, Date or other object inside it is not. me is a frozen copy of its plain data, so your object itself is never frozen.

Profiles

ProfileWhat it does
{ kind: "web" }Better Auth as you configured it. The default.
{ kind: "service" }Hands no caller a new session, and reads the principal from bearer tokens only
{ kind: "kiosk", … }The web profile plus staff switching by PIN on shared devices (The kiosk profile)

On the service profile, the routes that start a session answer 404: /sign-in/*, /sign-up/*, the OAuth callbacks, and the plugins' routes that sign a user in. They are matched by Better Auth's endpoint path, whatever the URL's spelling. Any other route cannot create a session either, and no session cookie leaves access.handler. Your server's own calls to Better Auth's API with no request still sign users in.

The plugins it installs

createAccess installs Better Auth's jwt() plugin and its own Access plugin (plugin id aventara-access). Putting either in betterAuth.plugins is a type error, and is refused at run time with ACCESS_PLUGIN_DUPLICATE. So is a plugin that defines one of jwt()'s endpoints. Every other Better Auth plugin passes through as it is.

What the instance holds

MemberWhat it is
access.handlerBetter Auth's request handler, (Request) => Promise<Response>, with Access's routes
access.apiBetter Auth's server API, as auth.api
access.optionsThe options Better Auth runs with: your block plus the installed plugins. Your block's plain data (its plain objects and arrays) is a frozen copy taken when createAccess ran, so changing it in your block afterwards does not reach Better Auth. Plugin objects, functions and class instances (the adapter, a database client or pool) are kept as you gave them and not frozen
access.$InferTypes only: Session, Me (the principal) and Claims
access.policyThe policy engine. It is built on first read.
access.state({ headers })The request's state, { readonly me }. me is a copy of the plain data principal returned (its plain objects and arrays, all the way down), frozen, and the state holds it read-only, so no code that runs during the request can replace or change the principal the policy decides for. A class instance, Map, Set, Date, typed array or getter is kept as you returned it and is not frozen: return plain data for the fields the rules read. Your own objects are never frozen. Under an OAuth access token, return a plain object: a Map or Set principal is not supported there, and a class instance loses its #private fields
access.registryThe registry's operator API, with registry: { enabled: true } only
access.clientWhere Access is mounted and the $access verbs it serves (The $access verbs)
access.kioskThe kiosk profile's operator API (The kiosk profile)
access.mcpThe framework's mcp.authorization, with mcp only (Agents over MCP)

Tokens and sessions

state reads the Authorization header first. When the header is there, the session cookie is never read: a valid Bearer token gives its identity, and anything else gives me: null.

A token is accepted when all of this holds:

  • It is signed by a key of the instance's key set (GET /api/auth/jwks). Access reads the set every 5 minutes, and at once (at most every 5 seconds) for a key it does not hold.
  • Its iss and its aud are both the instance's issuer. An audience list that merely includes it is refused.
  • It has sub, exp and iat, has not expired, and was issued no longer ago than tokenTtl. 5 seconds of clock skew are allowed.
  • It carries no claim beyond the declared ones.

The session cookie (profiles web and kiosk) is read from Better Auth's store, never from its cookie cache, so a revoked session gives me: null at once. On a request that may change something, the cookie counts only when the request comes from a site Better Auth trusts (its Origin, or Sec-Fetch-Site).

A token is not revoked. One issued before a sign-out keeps verifying until it expires, for at most tokenTtl (15 minutes by default). A service that must see a sign-out at once reads the session instead.

A session cookie set to sameSite: "none" is refused with ACCESS_OPTIONS_INVALID unless trustedOrigins names the origins your app is served from.

Better Auth's tables

Better Auth's tables go into your schema the way Better Auth's docs say. With Prisma, npx auth@~1.7.12 generate --config src/access.ts --yes writes Better Auth's models (and Access's own tables, when audit or registry is on) into prisma/schema.prisma. It reads the module's default export. Then migrate with Prisma and run aventara:prepare again. Its first run may print a Babel note that an identifier such as AvComputedFields "is not declared in Babel's scope tracker": a type the discovery artifact re-exports, and harmless.

Access closes Better Auth's tables to every request until you open them (Better Auth's tables as Resources).

User ids

Better Auth's ids are strings, and so is a token's sub: map the principal with id: sub and type it id: string. If User.id in prisma/schema.prisma is an Int (aventara new 1.0.0-rc.11 and earlier writes one), make it id String @id @default(cuid()) before you run generate, or generate writes String foreign keys (Session.userId, Account.userId) against that Int and prisma validate fails.

Numeric ids are refused in every app (ACCESS_OPTIONS_INVALID), and avaccess serve and avaccess keys refuse them before they migrate or listen. Under Better Auth's advanced.database.generateId: "serial", Better Auth does not enforce its single-use checks (private_key_jwt client assertions, DPoP proofs, SAML assertion ids, the magic-link, email-OTP and SIWE locks), and it reads an id with Number(), so ids written differently ("01", "1e0") name the same user. Numeric ids the database makes (generateId: false on Int id columns) have the same problem and are not supported either.

An app on 1.0.0-rc.1 with numeric ids refuses to start after upgrading until it moves to String ids:

  • Remove generateId: "serial".
  • Make User.id a String (id String @id @default(cuid())), with every id and reference to it in Better Auth's tables and Access's, and your own models' references to a user (Note.authorId Int becomes String).
  • Map the principal with id: sub where it was id: Number(sub), and type it id: string where the policies file has id: number.
  • A deployed database needs a data migration, not only the new schema: each id and foreign-key column becomes text, each row keeping its id as a string (1 becomes "1").
  • With Better Auth's OAuth provider, revoke the refresh tokens it issued while on numeric ids.