Scopes & Capability Grants
Let non-org principals (event attendees, shuttle drivers, vendors) reach a narrow slice of your data through a server-verified, relationship-derived scope carried in a signed JWT — with least-privilege scoped roles enforced across every pillar. Accountless principals (invite codes, partner tokens) mint the same scope via ctx.mintScope.
Org membership answers "which tenant is this caller in?" Scopes answer a different question: "this caller isn't an org member, but they're a confirmed attendee of event E — let them see exactly E's rows, as exactly the role they hold, and nothing else."
A scope is a tenant axis whose value is established by a relationship you prove once (at POST /scope/v1/enter) and then carry in a signed JWT claim — verified cryptographically on every later request with no database round-trip. It's the REST/JWT twin of the realtime ws-ticket.
When to use it
Reach for a scope when a principal must touch a bounded slice of an instance without being an org member:
- Event attendee — reads their event's announcements / sessions, not the org.
- Shuttle driver — sees only the passengers on their bus (a row slice), can DM them.
- F&B vendor — reads dietary counts, zero attendee rows.
- Visiting nurse / carrier / contractor — one patient's meds, one carrier's shipments.
Each is event-scoped but a distinct least-privilege role. Org members are unaffected — they keep their normal tenant access.
Most scope principals are signed-in users who prove a relationship at /scope/v1/enter. But some have no account at all — an attendee who arrives by email + invite code, a kiosk/handout code, a partner token. For those, an action proves the principal its own way and ctx.mintScope mints the same scope, no Better Auth user required.
The model
1. Declare relationships + scopes
A scope kind (event) declares named roles, each conferred by a relationship whose from row carries the role (and optionally a sub-key for row slicing):
// quickback.config.ts
authz: {
relationships: {
attendeeOf: { from: 'guests', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { status: 'confirmed' } },
organizerOf: { from: 'staff', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'organizer' } },
shuttleDriverOf: { from: 'staff', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'shuttleDriver' } },
},
scopes: {
event: {
requestField: 'eventId', // the body field the client sends to /enter
roles: {
attendee: { via: 'attendeeOf' },
organizer: { via: 'organizerOf' },
shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId'] }, // row-slice sub-key
},
},
},
}2. Enter the scope
POST /scope/v1/enter (session OR existing JWT auth)
{ "eventId": "evt_123" }The server probes every declared role for the proposed instance (one DB touch), and mints the proven roles + sub-keys into a signed JWT scope claim, returned via the set-auth-token header and the body:
// a shuttle driver who is also a confirmed guest
"scope": { "event": { "id": "evt_123", "roles": ["attendee", "shuttleDriver"], "shuttleId": "shA" } }The client sends that JWT as Authorization: Bearer … on subsequent requests. The auth middleware verifies the signature and sets ctx.scope — no DB. The claim is carried forward on every authed call (rolling refresh) for up to 15 minutes from the last actual relationship proof — the mint stamps a proof timestamp (sct) that the refresh carries forward verbatim and checks against the carry-forward cap. Past the cap the refresh stops, the token expires within one TTL, and the client re-proves via enter (or any namespace route that mints) to restart the window.
Invariant: the client only proposes the instance id (from the URL). The server proves membership before signing. No client-asserted scope is ever trusted.
3. Gate the pillars
Scoped roles are referenced everywhere a pillar takes a role as the namespaced identifier scope:<kind>:<role>:
// features/guests/guests.ts — the manifest, row-sliced + PII-masked
export default feature('guests', {
columns: { /* … eventId, shuttleId, email, … */ },
// org staff see the whole event; a shuttle driver sees only their bus
firewall: {
any: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ all: [
{ field: 'eventId', equals: 'ctx.scope.event' },
{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }, // sub-key row slice
] },
],
},
masking: {
email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } }, // hidden from the driver
},
read: {
views: {
manifest: {
fields: ['id', 'nameAtInvite', 'shuttleId', 'pickupLocation'],
access: { roles: ['admin', 'scope:event:organizer', 'scope:event:shuttleDriver'] },
},
},
},
});| Pillar | How it reads the scoped role |
|---|---|
| Firewall (rows) | all/any arms keyed on ctx.scope.<kind> (+ sub-keys) — see Firewall |
| Access (verbs/views) | access.roles: ['scope:event:shuttleDriver'] on CRUD, actions, and views |
| Masking (fields) | masking.<col>.show.roles includes / excludes scope:event:<role> |
The org / scope split (security)
Scoped roles never merge into ctx.roles. A scope:* identifier matches only the verified scope claim (ctx.scope.<kind>.roles); a bare org role (admin, member) matches only ctx.roles. The compiler enforces the split so an externally-provisioned shuttleDriver can never satisfy an admin gate by string coincidence, and a tampered scope claim can't impersonate an org role. This is the precondition for admitting limited outside users at all.
Sub-keys (row slicing)
A role's subKeys name columns on the relationship's from row that are projected into the claim. A firewall arm then slices on them:
{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }Because sub-key arms compose with all, an absent sub-key denies (sql\1 = 0`) rather than widening — a driver can never see *all* shuttles by simply lacking the claim. The scope accessor is always emitted null-safe (ctx.scope?.event?.shuttleId`), so an org caller who never entered the scope hits a clean deny, not a 500.
Set-valued sub-keys
A driver may cover more than one bus. Append a [] marker to the
sub-key name to project every matched from row's value into the claim
as a string[]:
roles: {
shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId[]'] }, // set-valued
}The [] is a marker only — it's stripped from the column name (the
relationship's from row still reads shuttleId). At /scope/v1/enter the
proven rows union their values, so the claim carries an array:
"scope": { "event": { "id": "evt_123", "roles": ["shuttleDriver"], "shuttleId": ["shA", "shC"] } }The matching firewall arm — written exactly as for the scalar case
({ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }) — is
rewritten by the compiler to an inArray row-slice:
WHERE shuttle_id IN (ctx.scope?.event?.shuttleId ?? [])The ?? [] default makes an absent claim lower to inArray(col, []) — a
clean DENY, never a malformed SQL fragment or a 500. A scalar sub-key (no
[] marker) is untouched and keeps lowering to eq. You write the same
equals arm either way; declaring shuttleId[] vs shuttleId in the scope
role is the only switch.
Auto-stamping the scope column on insert (q.scope)
q.scope('organization') auto-stamps the tenant column on write, the same
way q.scope('owner') stamps ownerId. A relationship-derived kind works
identically: q.scope('event') auto-stamps the column from the verified
scope claim (ctx.scope.event.id) on insert — no need to pass eventId
in the request body:
export const eventNotes = q.table('event_notes', {
id: q.id(),
eventId: q.scope('event'), // INSERT autofill from ctx.scope.event.id
body: q.text().required(),
organizationId: q.scope('organization'), // INSERT autofill from ctx.activeOrgId
});The stamp is insert-only and null-safe — the generated CREATE handler
writes eventId: ctx.scope?.event?.id, so it never binds undefined. It
does not add a read-time WHERE filter (that's the firewall's job; a
read scoped to eventId = ctx.scope.event.id would be wrong here). Infra
kinds map to their session fields (organization → ctx.activeOrgId,
owner → ctx.userId, team → ctx.activeTeamId); any other kind maps to
ctx.scope.<kind>.id.
Namespace scope-claim refresh
A scope is normally minted at POST /scope/v1/enter. When you also mount a
namespace
that gates /event/:eventId/* on the same relationship, the namespace
resolver can refresh ctx.scope.<kind> in memory for the duration of that
request — so an attendee hitting /event/evt_123/announce gets
ctx.scope.event = { id: 'evt_123', roles: ['attendee'] } without a separate
enter round-trip. The refresh runs after the gate (the gate proves the
relationship; the refresh stamps the proven role from the path param).
This is a strictly-guarded convenience, and it stamps per matched lane. The
resolver records which via: lane opened the gate (first-match-wins) and stamps
only that lane's scope role — an attendee who matched attendeeOf gets
roles: ['attendee']; an organizer who matched organizerOf gets
roles: ['organizer']. A caller can never receive a role they didn't prove this
request.
- Relationship lanes each confer their own scope role, stamped only when that exact lane matched — so a multi-lane
via:(e.g.via: ['attendeeOf', 'organizerOf']) refreshes the matched lane's role, not an ambiguous union. - A bulk-grant lane (
{ roles: [...] }or{ team: true }) confers no scope role — it opens the gate via the caller's org role without proving any relationship, so there is nothing to stamp (stamping one would forge it). The gate still passes; the caller is simply authorized by their org role.
The refresh described so far is in-memory and single-request. To also hand the
caller a persisted scope token, opt the namespace into mintScope — see
below.
Gotcha: a namespace only emits its resolver when it claims a standalone action
This is the single most common surprise (it has tripped up a customer): a
namespace emits its resolver — and therefore the scope refresh and the
mintScope
mint — only when at least one STANDALONE action's path: falls under the
namespace prefix.
A standalone action declares its own path: (e.g.
path: '/event/:eventId/home'), so the compiler can match it against the
namespace prefix and hoist it into the synthetic namespace routes file.
Record-based actions are different — they mount under their resource's CRUD
routes (/api/v1/guests/:id/checkin), which live outside the
/event/:eventId prefix. They never match the prefix, so they never get
claimed.
A namespace that contains only record-based actions is therefore inert:
nothing matches the prefix, no synthetic routes file is emitted, and so there
is no resolver, no scope refresh, and no mint — even though the namespace
is declared and validates cleanly. The middleware you expected to gate
/event/:eventId/* simply isn't there.
The fix is to give the namespace at least one standalone action under the
prefix. In q-events, eventHome exists precisely for this — a standalone
GET /event/:eventId/home that pulls the resolver (and the §5.3 mint) into
existence:
// features/events/actions/eventHome.ts
export default defineAction({
path: '/event/:eventId/home', // ← standalone path UNDER the namespace prefix
method: 'GET',
input: z.object({}),
access: { roles: ['scope:event:attendee', 'scope:event:organizer', 'manager+'] },
async execute({ db, ctx, c }) { /* … */ },
});With that action present, the eventScope resolver is emitted, runs the
attendeeOf probe once per /event/:eventId/* request, stamps
ctx.scope.event, and (because the namespace sets mintScope: true) mints the
scope token. Without a standalone action under the prefix, none of that fires.
mintScope — mint the scope token from the namespace resolver
A scope is normally minted by an explicit POST /scope/v1/enter round-trip.
Setting mintScope: true on a namespace folds that mint into the
namespace resolver: on a successful relationship probe the resolver MINTS a
persisted scope JWT and returns it via the set-auth-token header — so a
native client hitting /event/:eventId/* gets a scope token without ever
calling /scope/v1/enter.
// quickback.config.ts
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'linkedUserId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
loads: 'guests',
exposeAs: 'guest',
},
},
namespaces: {
eventScope: {
prefix: '/event/:eventId',
via: ['attendeeOf'], // one or more lanes — mints the matched lane's role
mintScope: true, // ← fold the scope-token mint into the resolver
},
},
}A confirmed attendee then hits the standalone action under the prefix:
GET /api/v1/event/evt_123/home
Authorization: Bearer <existing-jwt>
200 OK
set-auth-token: <new-scoped-jwt> ← carries scope.event.roles = ["attendee"]The client stores the set-auth-token value and sends it as the bearer on
every subsequent /event/:eventId/* request. The JWT carries
scope.event = { id: 'evt_123', roles: ['attendee'] }, so the scoped-role
access checks (scope:event:attendee) pass with no DB touch and no second
enter call.
mintScope is opt-in per namespace and defaults to false. With it off,
the resolver does the in-memory ctx.scope.<kind> refresh only (the section
above); subsequent requests must still call /scope/v1/enter for a persisted
token.
Per-matched-lane minting (forging safety)
The mint stamps the scope role of whichever lane matched, gated on the first-match-wins probe — the same per-lane discipline as the in-memory stamp, applied here with higher stakes: a minted token persists for its whole TTL, so it can only ever carry the role the caller actually proved this request.
- A multi-lane
via:(e.g.via: ['attendeeOf', 'organizerOf']) mints the matched lane's role —attendeeOf→scope.event.roles = ['attendee'],organizerOf→['organizer']. The resolver records which lane opened the gate, so there is no ambiguity. - A bulk-grant lane (
{ roles: [...] }or{ team: true }) mints no scope role — it opens the gate via the caller's org role without proving a relationship. The gate passes; the caller carries their org role, not a minted scope role.
The realtime ws-ticket can authorize through this same namespace
via:, issuing a per-matched-lane WebSocket ticket — so the socket gate and the REST gate stay identical.
In every suppressed case the gate still authorizes the request; only the mint
(and the in-memory refresh) are skipped, and those callers obtain a scope
token through /scope/v1/enter as usual. Flipping mintScope: true can never
forge a scope role — the guard, not the flag, is the gate.
TTL and best-effort semantics
- TTL is the scope-token TTL —
auth.jwt.expiresIn(default 180s), clamped to a hard ≤180s ceiling at every signing site that emits a scope claim: both proven mints (/scope/v1/enter, the namespace resolver) and the middleware's rolling refresh. A longer-lived scope token would widen the post-revocation replay window past the spec bound, so the clamp is non-negotiable — anauth.jwt.expiresInabove the ceiling applies to plain tokens but never survives into a scope-bearing one. - The mint is gated by the same proven-relationship guard as the in-memory stamp. It runs after the gate has granted access, reading the instance id from the proven path param (
:eventId) — never from request input — so the mintedscope:<kind>:<role>is backed by exactly the proof/scope/v1/enterrequires. The token is byte-identical to one/scope/v1/enterwould produce: same shared mint helper, same carry-forward of any other verified scope kinds already on the incoming token (the freshly-proven kind takes precedence; siblings survive the re-mint). - The mint is best-effort. A mint failure (e.g. a missing signing secret) never fails the request — the in-memory stamp already authorized it; the caller simply doesn't receive a refreshed token on that response and can fall back to
/scope/v1/enter.
acceptScope — accept an incoming scope claim as proof of entry
mintScope is the resolver minting a scope claim after a ctx.userId lane
matches. acceptScope: '<kind>' is the inverse: it lets the same resolver
accept an already-minted ctx.scope.<kind> claim as its own proof — so one
/event/:eventId/* handler serves both the Better-Auth user (userId
relationship probe) and the sessionless scope principal (verified scope
JWT, no account). It's the exact fast-path the realtime
ws-ticket already has, ported to REST.
Without it, every scope-principal-facing namespaced action needs a parallel
PUBLIC twin (a separate handler with a hand-rolled requireScope check and
raw DB writes that bypass scoped-db) — double the surface, and "someone forgot
the twin" becomes a standing bug class. acceptScope retires the twin.
// quickback.config.ts
authz: {
relationships: {
inspectorOf: {
from: 'site_inspectors',
subject: { column: 'inspectorUserId', equals: 'ctx.userId' },
resource: { column: 'locationId' },
where: { status: 'active' },
loads: 'locations',
exposeAs: 'location',
},
},
scopes: {
location: {
requestField: 'locationId',
roles: {
// supplied-reachable: a signed-in user AND a sessionless principal
// converge on ONE scope:location:inspector.
inspector: { via: 'inspectorOf', subKeys: ['inspectorId'], subject: 'both' },
},
},
},
namespaces: {
locationScope: {
prefix: '/location/:locationId',
via: ['inspectorOf'],
acceptScope: 'location', // ← honor an incoming ctx.scope.location claim
// mintScope: true, // ← compose freely: a userId caller also re-mints
},
},
}A sessionless principal that already minted a token (via
ctx.mintScope or /scope/v1/enter)
then hits the namespaced action directly, no second round-trip:
GET /api/v1/location/loc_42/summary
Authorization: Bearer <scope-only-jwt carrying scope.location = { id: 'loc_42', roles: ['inspector'] }>
200 OKThe resolver reads ctx.scope.location, confirms its id equals the :locationId
param, sees the inspector role a supplied-reachable lane confers, and admits
the request — no ctx.userId, no DB relationship probe. ctx.location is
hydrated through its own firewall exactly as it is for the userId caller.
Two locks (both required, fail-closed)
acceptScope is named-string only — always the explicit kind, never a bare
true (there's no inference from the :param). Default absent → the resolver
never admits a scope principal (unchanged behavior). Beyond naming the kind,
the compiler enforces a second lock:
acceptScope: '<kind>'opts this namespace's REST surface in, and- at least one relationship lane of the namespace confers a
<kind>role declaredsubject: 'supplied' | 'both'(supplied-reachable).
A subject: 'user' role can be minted or refreshed by the namespace but can
never be accepted from a token — a user-only role must be proven by a live
ctx.userId probe. If acceptScope names a kind whose every conferring lane is
subject: 'user', the compiler errors rather than emit a dead fast-path.
The compiler also checks the kind is declared and its requestField equals the
prefix's first :param.
Forging safety
A scope principal can't use acceptScope to reach anything its token doesn't
already authorize:
- The claim is verified, not asserted —
ctx.scopeis populated only from a JWT the auth middleware verified; the action can't write it. - Named kind, not any-kind — the fast-path reads
ctx.scope['<kind>']for the declared kind (stricter than the ws-ticket's any-key loop); a colliding foreign-kind id can't open the gate. - Instance pinned twice —
entry.id === :paramin the gate, andeq(loads.pk, :param)in hydration. The principal enters exactly the instance its claim proved. - Role → lane is exact and supplied-restricted — each accept arm is one
(role, lane)pair withsubject !== 'user'; aninspectorclaim matchesinspectorOf, never a sibling lane. - No new enforcement surface — firewall, masking,
accessarms, and scoped-db all read the samectx.scope/ctx.activeOrgIdthey read for a userId caller. New proof source, zero new enforcement surface.
The tenant (ctx.activeOrgId) is sourced from the pinned-loaded resource
(ctx.<exposeAs>.organizationId) — parity with the userId caller, not a new
grant. Fine-grained "own rows only" stays the resource author's job via a
firewall arm on ctx.scope.<kind>.subjectId, the same arm that already
serves the userId principal.
Writes: audit fields for a userless principal
A scope principal has no ctx.userId, so the scoped-db audit wrapper — which
stamps created_by / modified_by — can't record a user. Rather than force a
parallel raw-db write path (which would re-scatter the exact duplication
acceptScope removes), the wrapper stamps the principal's synthetic, audit-only
id — the token's sub, scope:<kind>:<subjectId> — into those columns:
// A verified scope principal's write, through the normal scoped db:
await db.insert(vendorCheckins).values({ eventId, note });
// → created_by = modified_by = "scope:event:guest_abc123"The scope: prefix self-describes the actor (which kind, which subject), so no
separate "actor type" column is needed. This is the write-side companion to the
read fast-path: one handler body serves both a Better-Auth user and the
sessionless principal, on reads and writes alike.
It stays fail-closed: the relaxation fires only for ctx.scopePrincipal === true. A write that reaches the wrapper with no userId and no scope
principal (genuine auth-context loss) still throws — the audit layer never
silently logs an unauthenticated write. For a real system-side write outside any
request, use the unwrapped db (createDb) directly, as before.
Sessionless scope mint — ctx.mintScope
POST /scope/v1/enter and the namespace resolver both prove the relationship by
ctx.userId — so they require a Better Auth account. But a legitimate
principal often has none: an event attendee who arrives by email + invite
code, holding an opaque session token you validate yourself, who must never
get a global account. The shape is general — magic links, kiosk/handout codes,
partner-issued API keys, expiring share links that need row-level scope.
For these, prove the relationship your own way inside an action, then ask Quickback to mint a disciplined scope token from the row you proved.
1. Opt the role into supplied-subject proof
Add subject to the scope role (default 'user'):
authz: {
scopes: {
event: {
requestField: 'eventId',
roles: {
// 'both' → the SAME scope:event:attendee for a logged-in user
// (via /enter) AND a sessionless invite-holder (via ctx.mintScope).
attendee: { via: 'attendeeOf', subKeys: ['guestId'], subject: 'both' },
},
},
},
}'user'(default) —/scope/v1/enteronly, proven bysubject.column = ctx.userId. Unchanged.'supplied'— sessionless only.ctx.mintScopemints it;/enterwon't (there's no user to probe).'both'— either path. A user-attendee and a sessionless invite-attendee converge on onescope:event:attendee— one firewall arm, one masking rule, both principals.
This opt-in is the single fail-closed switch: without 'supplied' or 'both', no relationship is mintable by a supplied id.
2. Mint from the proven row
ctx.mintScope is hydrated on ctx in any action when a subject: 'supplied' | 'both' role is declared (including PUBLIC actions — the
canonical caller has no token yet). The action proves the principal however it
likes, then hands Quickback only the conferring row's primary key:
// features/events/actions/enterEvent.ts
export default defineAction({
path: '/enter-event',
method: 'POST',
input: z.object({ code: z.string() }),
access: { roles: ['PUBLIC'] }, // unauthenticated entry
async execute({ ctx, input }) {
const guestRowId = await validateInviteCode(ctx, input.code); // YOUR proof, YOUR table
const { token } = await ctx.mintScope({ via: 'attendeeOf', subject: guestRowId });
return { token }; // client sends it as `Authorization: Bearer …` thereafter
},
});You assert nothing about the scope. Quickback re-reads the attendeeOf row
by primary key and takes the role, the resource id (eventId), the sub-keys,
and the subject id off that row — so a buggy or hostile action can't forge
organizer, can't pick a different event, and can't widen the slice. The only
thing delegated to you is which row — exactly the opaque-credential check that
has to be custom code.
| What you supply | What Quickback reads off the row |
|---|---|
via (a declared supplied/both relationship) + subject (the from-row PK you proved) | the role, id (resource), sub-keys, and subjectId — none caller-asserted |
The where: filter runs on the mint probe exactly as at /enter — a cancelled
or pending row confers nothing, so flipping a guest's status revokes them
within one mint cycle (≤180s). Re-prove by calling ctx.mintScope again
(your own refresh endpoint does exactly that). The by-PK probe does not apply
the relationship's tenant firewall (a sessionless principal has no
activeOrgId yet — the proven row is what establishes scope), but matching by
primary key + the where: filter keeps it bounded and fail-closed.
3. The scope-only token
ctx.mintScope signs a scope-only JWT — it carries the scope claim and
nothing else. There is no user identity: the auth middleware builds a context
with ctx.userId undefined, so every user-, org-, and owner-gated path
fails closed automatically (an owner firewall compares against undefined
→ matches nothing; org-role gates see empty ctx.roles). The AUTHENTICATED
and USER pseudo-roles exclude a scope principal by design — it is
authenticated as a scope, not as "any signed-in user". Only
scope:<kind>:<role> gates and ctx.scope.<kind> firewall arms admit it.
Everything downstream — REST firewall, masking, scoped-role access, the realtime
ws-ticket — then treats the sessionless attendee identically to a
user-proven one. There is no new enforcement surface; the principal is just
scope:event:attendee.
subjectId — scoping to the person, not just the instance
A user-proven scope scopes self-data (an attendee's travel, dietary, todos) by
ctx.userId. A sessionless attendee has no ctx.userId, so the proven
subject's identity rides in the claim as a reserved subjectId — the
conferring row's primary key:
"scope": { "event": { "id": "evt_123", "roles": ["attendee"], "subjectId": "gst_42" } }Scope self-data on it with one firewall arm:
{ field: 'guestId', equals: 'ctx.scope.event.subjectId' }subjectId is populated on both proof paths — /scope/v1/enter projects
the matched row's PK too — so the arm above is one arm that serves both a
logged-in attendee and a sessionless one. (It is distinct from
ctx.scope.event.id, which is the instance — the event.)
Realtime
A scope-only token authorizes the realtime
ws-ticket directly off its proven
ctx.scope claim — no ctx.userId re-probe. The sessionless attendee joins the
same per-event rooms (and per-attendee rooms keyed on subjectId) as a
user-attendee, carrying the same scope role on the ticket. So the REST gate and
the socket gate stay identical for accountless principals too.
Revocation & TTL
The scope claim is trusted between proofs — it is never re-checked against the database while it rides. Two bounds keep that window finite:
- The TTL (default 180s,
auth.jwt.expiresIn, clamped ≤180s on scope-bearing tokens) bounds an idle client: a token that stops being used dies within one TTL. - The carry-forward cap (15 minutes) bounds an active client: the rolling refresh re-signs the claim on every authed call, but only while the claim's proof timestamp (
sct, stamped exclusively by the proven mint sites) is younger than the cap. Past it the refresh refuses, the token expires, and the nextenter(or namespace-route mint) re-probes the relationship — which won't re-mint a revoked role.
So removing a guest/staff row takes effect within cap + one TTL for a client that keeps polling, and within one TTL once it goes idle. Two ways to tighten that:
auth.jwt.revocationCheck: 'kv'(v0.45+, Cloudflare runtime) — sub-TTL revocation: updating or deleting a conferring relationship row stamps a revocation timestamp in KV, and the bearer fast-path rejects a carried scope claim whose proof (sct) predates it. See auth config.viaarms — for tables where any caching is too wide, gate with aviaarm instead of a barectx.scope.<kind>arm:viaemits a live subquery against the relationship table on every request, immune to a stale claim entirely.
Reference
// AppContext (generated lib/types.ts)
ctx.scope?: Record<string, { id: string; roles?: string[]; subjectId?: string; [subKey: string]: string | string[] | undefined }>;
// authz.scopes.<kind>
{
requestField: string; // body field carrying the instance id
roles: Record<string, {
via: string;
subKeys?: string[]; // 'col' → eq, 'col[]' → inArray
subject?: 'user' | 'supplied' | 'both'; // who selects the conferring row (default 'user')
}>;
}
// ctx.mintScope — present in actions when a `subject: 'supplied' | 'both'` role is declared
ctx.mintScope(opts: { via: string; subject: string; setAuthToken?: boolean }):
Promise<{ token: string; scope: { id: string; roles: string[]; subjectId?: string } }>;requestFieldmust equal every role-relationship'sresource.column.- Each relationship's
fromtable must exist and ship a generated firewall (it can't beexception). - A
subKeysentry ending in[]is set-valued (lowers toinArray, minted asstring[]); without the marker it's scalar (eq, minted asstring). subjectdefaults to'user'.'supplied'/'both'enablectx.mintScope(sessionless mint);'supplied'is not mintable via/scope/v1/enter.ctx.mintScope({ via, subject })mints a scope-only token (no user identity,ctx.userIdundefined) from thefrom-row whose PK issubject— reading the role, resource id, sub-keys, andsubjectIdoff that row.subjectIdis the conferring row's primary key, populated on both proof paths — scope self-data onctx.scope.<kind>.subjectId.q.scope('<kind>')auto-stamps the column fromctx.scope.<kind>.idon insert.- Generated route:
POST /scope/v1/enter(mounted only whenauthz.scopesis declared).
Firewall - Data Isolation
Automatically isolate data by user, organization, or team with generated WHERE clauses. Prevent unauthorized data access at the database level.
Permissions
Name a grant rule once with anyOf / allOf / not over relationships, org roles, scoped roles, and pseudo-roles — then reference it from any firewall as { permission: 'x' }.