Access - Role & Condition-Based Access Control
Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.
Define who can perform read and write operations and under what conditions.
Which primitive do I need?
Start here. Most apps need only the first row — this page, and nothing below it.
| The rule you're expressing | Use | Where |
|---|---|---|
| "Anyone in the org, with this role" | roles: ['member+'] / ['admin+'] | this page |
| "Any signed-in caller" | roles: ['AUTHENTICATED'] | below |
| "Only the caller's own rows" | roles: ['USER'] + an ownerId firewall | below |
| "Anyone at all, signed in or not" | roles: ['PUBLIC'] | below |
| "Platform operators, regardless of org" | userRole: ['appmanager'] | below |
| The same expression, repeated across many features | authz.roles / authz.rules | Named rules |
| "The caller has a row in my table linking them to this record" | authz.relationships | Relationships |
| A permission graph — derived, inherited, share links | FGA | FGA |
If org membership answers it, stop here
Org membership is decided before your handler runs and arrives as ctx.roles. Do not
declare a relationship to ask "is this person in the org?" — a relationship's from: must
be one of your feature tables, and the compiler rejects one pointing at Better Auth's
organization_memberships. See the authorization ladder.
To validate a submitted user id (an assignee), query AUTH_DB from an action — see Scoped DB.
Basic Usage
// features/applications/applications.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';
export const applications = sqliteTable('applications', {
id: text('id').primaryKey(),
candidateId: text('candidate_id').notNull(),
jobId: text('job_id').notNull(),
stage: text('stage').notNull(),
notes: text('notes'),
organizationId: text('organization_id').notNull(),
// ── quickback:audit (compiler-managed — edits are validated, not merged) ──
createdAt: text('created_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
modifiedAt: text('modified_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
createdBy: text('created_by'),
modifiedBy: text('modified_by'),
deletedAt: text('deleted_at'),
deletedBy: text('deleted_by'),
});
export default defineTable(applications, {
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
guards: { createable: ["candidateId", "jobId", "notes"], updatable: ["notes"] },
read: {
access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
},
create: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
update: { access: { roles: ["owner", "hiring-manager", "recruiter"] } },
delete: { access: { roles: ["owner", "hiring-manager"] } },
});Role Hierarchy
If your project uses a tiered role system (e.g., member < admin < owner), you can define a hierarchy in your config and use the + suffix to mean "this role and above":
// quickback.config.ts
export default defineConfig({
name: "my-app",
auth: {
roleHierarchy: ['member', 'admin', 'owner'], // lowest → highest
},
// ...
});Then in your resource definitions:
read: { access: { roles: ["member+"] } }, // member, admin, owner
create: { access: { roles: ["admin+"] } }, // admin, owner
delete: { access: { roles: ["owner"] } }, // owner only (no +)The + suffix expands at compile time — "member+" becomes ["member", "admin", "owner"] in the generated code. You can mix hierarchical and exact roles: roles: ["member+", "finance"] expands to ["member", "admin", "owner", "finance"].
- Roles without
+are exact matches (no expansion) - Pseudo-roles / reserved markers (
PUBLIC,AUTHENTICATED,SESSION,SCOPED,USER,ADMIN,SYSADMIN) cannot use the+suffix — they're terminal markers - Using
+with a role not in the hierarchy throws a compile error - Using
+without configuringauth.roleHierarchythrows a compile error
Custom org roles
You're not limited to Better Auth's built-in owner / admin / member —
any lowercase role name you reference in an access tree ("recruiter",
"hiring-manager", "support", …) is a custom org-membership role, matched
against ctx.roles like the built-ins.
As of v0.46, custom roles declared in access trees are auto-registered
with the Better Auth organization plugin, so members can actually be
provisioned into them: invite-member and update-member-role accept them
(previously they rejected custom roles with 400 ROLE_NOT_FOUND).
Auto-registered roles carry member-equivalent Better Auth permissions —
registration is about assignability; your access trees remain the
authorization model. If you pass your own roles / ac to the organization
plugin options, those win and auto-registration is skipped.
Configuration Options
interface Access {
// Org-membership roles (OR logic — user needs at least one)
// Matched against ctx.roles. Use "role+" suffix for hierarchy expansion.
roles?: string[];
// User-table role (OR logic). Matched against ctx.userRole from the
// user.role column — independent of org membership. See userRole below.
userRole?: string[];
// Record-level conditions
record?: {
[field: string]: FieldCondition;
};
// Combinators
or?: Access[];
and?: Access[];
}userRole vs roles
roles and userRole check two different fields and are often confused — get this wrong and you'll grant access too broadly.
| Primitive | Checks | Source | Example values |
|---|---|---|---|
roles | ctx.roles | Org membership roles | "owner", "admin", "member" |
userRole | ctx.userRole | The user.role column (Better Auth / platform control-plane roles) | "user", "appmanager", "sysadmin" |
In multi-tenant mode, roles: ["admin"] means "org admin" — not a platform-wide role. An org-admin of Acme Corp has no elevated rights across other orgs. If you want platform control-plane operators through regardless of org membership (CMS, Better Auth admin surfaces, support tooling), use userRole: ["appmanager"]. If you need true cross-tenant data-plane access, use roles: ["SYSADMIN"] (with cms: { sysadmin: true }).
Pinned organization mode does not change this split. roles still come from organization membership in the pinned org, while userRole still comes from Better Auth's user.role column.
When to use userRole
// Platform control-plane users can see SSNs on every customer record, regardless of org
masking: {
ssn: { type: 'ssn', show: { userRole: ['appmanager'] } },
}
// Platform ops (user.role === 'appmanager') OR the org owner can refund
access: {
or: [
{ userRole: ['appmanager'] },
{ roles: ['owner'] },
],
}
// CMS-internal resource: only platform control-plane users
read: { access: { userRole: ['appmanager'] } },
create: { access: { userRole: ['appmanager'] } },
update: { access: { userRole: ['appmanager'] } },
delete: { access: { userRole: ['appmanager'] } },When roles and userRole both appear in a single Access node, they're combined with AND — the user must satisfy both. Use or: to express "one or the other".
userRole doesn't expand with the + hierarchy suffix — user-table roles are flat (typically just "user", "appmanager", and "sysadmin"). If you've added custom user-table roles via Better Auth, list them explicitly.
Pseudo-roles
Quickback ships seven reserved UPPERCASE markers. Six are real pseudo-roles handled directly by the compiler; ADMIN is retained only as a reserved legacy marker so the compiler can fail loudly with migration guidance. The casing carries the boundary: UPPERCASE = compiler-owned marker, lowercase = real role from a plugin's table (e.g. members.role from the org plugin).
| Role | Auth gate | Compile-time requirement | Runtime check |
|---|---|---|---|
PUBLIC | bypassed | none — explicit opt-in for unauthenticated access | always passes (auth skipped) |
AUTHENTICATED | required | none | ctx.authenticated === true (a full session or a scope principal — the wrapper over SESSION ∪ SCOPED) |
SESSION | required | none | a full Better Auth session only — ctx.authenticated && !ctx.principal && !ctx.scopePrincipal |
SCOPED | required | none | a sessionless scope principal only — ctx.authenticated && ctx.scopePrincipal === true |
USER | required | resource firewall must scope by userId | a real signed-in user — ctx.authenticated AND not a scope principal (!ctx.scopePrincipal) AND (ctx.userRole unset OR === "user"). A sessionless scope caller does not satisfy USER (use SCOPED / scope:<kind>:<role>); AUTHENTICATED is the marker that admits both flavors. |
ADMIN | required | reserved legacy marker — compiler rejects it | false |
SYSADMIN | required | cms: { sysadmin: true } must be set on the project | ctx.authenticated && ctx.userRole === "sysadmin" |
You can mix pseudo-roles with real roles in the same list — they're OR'd together.
PUBLIC — unauthenticated access
Use roles: ["PUBLIC"] for public-facing endpoints like contact forms, public listings, or webhooks. Skips both the auth gate and the role check.
access: { roles: ["PUBLIC"] }Important:
PUBLICskips authentication and role checks — anyone can call the endpointPUBLICis the explicit, uppercase opt-in for unauthenticated access. It works on every access node — reads, top-level writes (create/update/delete/upsert), views, and actions. The compiler trusts the marker; row-level safety comes from the firewall, masking, and (for token-gated endpoints like RSVP confirm, magic-link redemption, List-Unsubscribe-Post, webhook ingest) your handler's own validation. Anonymous write endpoints typically pairroles: ["PUBLIC"]withfirewall: { exception: true }on the resource so the row predicate doesn't reject every unauthenticated caller.- Firewall still applies — data is scoped by the firewall (organization, owner, team)
- Tree semantics:
PUBLICadmits anonymous callers from anywhere in the access tree, not just a flatroleslist. Anor:arm containingPUBLICadmits anonymous callers past the authentication gate; anand:group admits them only when every arm does. The access, org, and firewall layers still evaluate the full tree afterwards. (Before v0.46 the emitted auth gate only honoredPUBLICin a flatroleslist — a nestedPUBLICarm incorrectly answered401.) - Every
PUBLICaction invocation is mandatory audit logged to the security audit table (IP address, input, result, timing) - The wildcard
"*"is not supported — using it throws a compile-time error - Tables with no isolation columns and any
PUBLICroute do not requirefirewall: [{ exception: true }]
For org-scoped tables, PUBLIC routes still filter by organization. Since the user isn't authenticated, the organization ID must be passed as a query parameter:
GET /api/v1/listings?organizationId=org_abc123The firewall WHERE clause is always enforced. If no organizationId is provided, the API returns a 403 with code ORG_REQUIRED.
?organizationId= is an account/public addressing surface. A request using a
configured delegated principal cannot use it to acquire account organization
authority: the generated route returns 403 CROSS_TENANT_FORBIDDEN and never
adds activeOrgId or account roles to the delegated AppContext. Delegated
scope continues to come only from the verified principal claims and database
RLS policy.
// quickback/features/listings/listings.ts
import { q, defineTable } from '@quickback/compiler';
// Public listing page — anyone can browse, but scoped to an org
export const listings = q.table('listings', {
id: q.id(),
title: q.text().required(),
organizationId: q.scope('organization'),
...q.audit(),
...q.softDelete(),
});
export default defineTable(listings, {
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
read: { access: { roles: ["PUBLIC"] } }, // Public browsing + detail
create: { access: { roles: ["admin", "member"] } }, // Auth required to create
});AUTHENTICATED — any authenticated caller (the wrapper)
Use roles: ["AUTHENTICATED"] for endpoints that any logged-in caller can hit, regardless of role or org membership. No plugin context required.
// Public events feed — any signed-in user can list/read; anonymous gets 401
read: { access: { roles: ["AUTHENTICATED"] } }AUTHENTICATED is the wrapper over two auth flavors — it admits both a full Better Auth session and a sessionless scope principal (a scope-token holder, e.g. an event attendee who isn't an org member; see Scopes). It excludes only external delegated principals (ctx.principal, admitted via principals:). When you need to distinguish the two flavors, use SESSION or SCOPED below; when either is fine, use AUTHENTICATED.
SESSION — a full session only
Use roles: ["SESSION"] when a route must admit only a full Better Auth session and not a sessionless scope principal. This is AUTHENTICATED minus scope-token callers.
// Account settings — a real logged-in user, never a scope-token attendee
read: { access: { roles: ["SESSION"] } }SCOPED — a scope principal only
Use roles: ["SCOPED"] for a route that is specifically for sessionless scope-token callers (a principal-only surface — e.g. a shared event link or kiosk token with no account). It admits any scope principal regardless of which scope; for a specific scoped role, use the scope:<kind>:<role> form instead (e.g. scope:event:attendee — see Scopes).
// Principal-only event surface — a scope token, no account required
read: { access: { roles: ["SCOPED"] } }Firewall coherence: if an access surface admits a scope caller (
SCOPEDor ascope:<kind>:<role>role) but the resource firewall can't filter that caller, the compiler fails the build — the caller would reach the handler yet see zero rows (a silent200 + []). Give the firewall a way to filter them: actx.scope.<kind>arm inside an{ any: [...] }group (compose org or scope — see Firewall), avia:relationship, orfirewall: [{ exception: true }].
USER — per-user records
Use roles: ["USER"] for resources where each user only sees their own records. Requires the firewall to scope by owner — the role marker is the access half of the pair, the firewall is the row-filter half.
The owner column is ownerId. Name the column ownerId and the firewall is auto-derived (WHERE ownerId = ctx.userId, plus auto-stamping on insert); userId is not auto-detected on feature tables and must be declared explicitly. See Firewall → ownerId vs userId.
// quickback/features/todos/todos.ts
import { q, defineTable } from '@quickback/compiler';
// "My todos" — each caller sees only their own rows
export const todos = q.table('todos', {
id: q.id(),
title: q.text().required(),
done: q.bool().default(false).required(),
ownerId: q.text().required(),
...q.audit(),
...q.softDelete(),
});
export default defineTable(todos, {
firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
read: { access: { roles: ["USER"] } },
create: { access: { roles: ["USER"] } },
update: { access: { roles: ["USER"] } },
delete: { access: { roles: ["USER"] } },
});If the firewall doesn't scope by owner, the compiler throws:
Resource "todos" uses roles: ["USER"] but the firewall does not scope by
ownerId. Add an ownerId column (auto-scoped) or an explicit owner predicate
(firewall: [{ field: "ownerId", equals: "ctx.userId" }, ...]). If the column
is named userId, declare it explicitly:
firewall: [{ field: "userId", equals: "ctx.userId" }]. If you want any
signed-in user to read all records, use roles: ["AUTHENTICATED"] instead.When the Better Auth admin plugin is enabled, USER matches user.role === "user" specifically — appmanagers and sysadmins are excluded. If the same route should serve both, express it explicitly with or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }].
ADMIN — legacy reserved marker (compile-time error)
roles: ["ADMIN"] is no longer supported. The compiler fails hard on any use of ADMIN so platform and tenant roles can't be confused.
// Platform control-plane audit log
read: { access: { userRole: ["appmanager"] } }Use one of these instead:
userRole: ["appmanager"]for platform control-plane accessroles: ["admin"]for org-membership admin accessroles: ["SYSADMIN"]for true cross-tenant access
SYSADMIN — cross-tenant DB-admin tier
Use roles: ["SYSADMIN"] for endpoints that should only be reachable by platform sysadmins — the cross-tenant DB-admin tier introduced in v0.15.0. Compiles to ctx.userRole === "sysadmin". Requires cms: { sysadmin: true } on the project config; without that flag, using SYSADMIN fails the compile so the role can't sit as dead code.
// Cross-tenant audit dashboard, sysadmin-only
read: { access: { roles: ["SYSADMIN"] } }SYSADMIN is distinct from userRole: ["appmanager"]. Appmanager is the platform control-plane tier (CMS shell, Better Auth admin surfaces, support tooling); sysadmin is the cross-tenant data-plane tier. The one place sysadmin gets implicit extra powers is the firewall escape hatch: when cms.sysadmin: true, every buildFirewallConditions emits an if (ctx.userRole === 'sysadmin') return undefined; (or just the soft-delete predicate when present) at the top — so sysadmins see every tenant's rows, not just their own org's. That escape hatch does NOT fire for userRole === 'appmanager'; appmanagers still go through the firewall like everyone else.
This separation exists so Better Auth admin-plugin powers (user management, impersonation, org creation) and DB-admin powers (cross-tenant data inspection) stay decoupled. A user can have user.role === 'appmanager' without DB-level cross-tenant access; a user can have user.role === 'sysadmin' without org-membership context. See CMS Configuration for the cms.sysadmin flag, the cross-tenant /auth/v1/admin/list-organizations endpoint, and the SPA's "All organizations" sentinel.
Mixing pseudo-roles with real roles
Pseudo-roles and lowercase real roles compose with OR:
// Record owner OR a platform control-plane user can read
read: { access: { or: [{ roles: ["USER"] }, { userRole: ["appmanager"] }] } }
// Org owner OR sysadmin
delete: { access: { or: [{ roles: ["owner"] }, { roles: ["SYSADMIN"] }] } }Note:
userRole: ["appmanager"]never bypasses auserIdfirewall scope. If you need cross-user visibility, expose a separate route withfirewall: { exception: true }or aSYSADMIN-gated cross-tenant surface.
Quick reference
// Unauthenticated public
access: { roles: ["PUBLIC"] }
// Any signed-in user, no scoping
access: { roles: ["AUTHENTICATED"] }
// Per-user records (requires userId firewall scope)
access: { roles: ["USER"] }
// Platform control-plane
access: { userRole: ["appmanager"] }
// Cross-tenant DB-admin tier
access: { roles: ["SYSADMIN"] }
// Org-membership roles with hierarchy expansion
// ("+" requires auth.roleHierarchy in quickback.config.ts — see Role Hierarchy above)
access: { roles: ["member+"] } // expands to ["member", "admin", "owner"]
access: { roles: ["admin+"] } // expands to ["admin", "owner"]
access: { roles: ["admin"] } // exact match (no expansion)// Field conditions - value can be string | number | boolean
type FieldCondition =
| { equals: value | '$ctx.userId' | '$ctx.activeOrgId' }
| { notEquals: value }
| { in: value[] }
| { notIn: value[] }
| { lessThan: number }
| { greaterThan: number }
| { lessThanOrEqual: number }
| { greaterThanOrEqual: number };Evaluation Order
Generated /:id and batch handlers evaluate access in this fixed order:
- Auth gate — 401 if unauthenticated
- Pre-record access — role-only check (skips
access.recordpredicates) - Firewall query —
WHEREclause filters by ownership - Post-record access — full check including record-level predicates
- Masking — applied to the response
Step 2 closes the 404-vs-403 ID-probe channel: an unauthorized caller without
a matching role gets 403 before the database is touched, so they can't tell
apart "row exists out-of-scope" from "row doesn't exist." A caller with the
right role but the wrong tenant gets 403 by default (firewall reveals via
the access layer); set
firewallErrorMode: 'hide' to return an
opaque 404 instead.
Function-form access (access: async (ctx, record) => ...) is opaque to
the pre-record phase and always passes there; it's fully evaluated post-record.
Context Variables
Use $ctx. prefix to reference context values in conditions:
// User can only view their own records
access: {
record: { userId: { equals: "$ctx.userId" } }
}
// Nested path support for complex context objects
access: {
record: { ownerId: { equals: "$ctx.user.id" } }
}AppContext Reference
| Property | Type | Description |
|---|---|---|
$ctx.userId | string | Current authenticated user's ID |
$ctx.activeOrgId | string | User's active organization ID |
$ctx.activeTeamId | string | null | User's active team ID (if applicable) |
$ctx.roles | string[] | User's roles in current context |
$ctx.isAnonymous | boolean | Whether user is anonymous |
$ctx.user | object | Full user object from auth provider |
$ctx.user.id | string | User ID (nested path example) |
$ctx.user.email | string | User's email address |
$ctx.{property} | any | Any custom context property |
Function-Based Access
For complex access logic that can't be expressed declaratively, use a function:
update: {
access: async (ctx, record) => {
// Custom logic - return true to allow, false to deny
if (ctx.roles.includes('admin')) return true;
if (record.ownerId === ctx.userId) return true;
// Check custom business logic
const membership = await checkTeamMembership(ctx.userId, record.teamId);
return membership.canEdit;
}
}Function access receives:
ctx: The full AppContext objectrecord: The record being accessed (for get/update/delete operations)
Firewall - Data Isolation
Automatically isolate data by user, organization, or team with generated WHERE clauses. Prevent unauthorized data access at the database level.
Masking - Field Redaction
Hide sensitive data from unauthorized users with field masking. The compiler detects sensitive column names and makes you decide, per column, whether to mask them.