Quickback Docs

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.scopeno 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'] },
      },
    },
  },
});
PillarHow 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 (organizationctx.activeOrgId, ownerctx.userId, teamctx.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 — attendeeOfscope.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 TTLauth.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 — an auth.jwt.expiresIn above 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 minted scope:<kind>:<role> is backed by exactly the proof /scope/v1/enter requires. The token is byte-identical to one /scope/v1/enter would 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 OK

The 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:

  1. acceptScope: '<kind>' opts this namespace's REST surface in, and
  2. at least one relationship lane of the namespace confers a <kind> role declared subject: '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 assertedctx.scope is 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 twiceentry.id === :param in the gate, and eq(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 with subject !== 'user'; an inspector claim matches inspectorOf, never a sibling lane.
  • No new enforcement surface — firewall, masking, access arms, and scoped-db all read the same ctx.scope / ctx.activeOrgId they 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/enter only, proven by subject.column = ctx.userId. Unchanged.
  • 'supplied' — sessionless only. ctx.mintScope mints it; /enter won't (there's no user to probe).
  • 'both' — either path. A user-attendee and a sessionless invite-attendee converge on one scope: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 supplyWhat 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 next enter (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.
  • via arms — for tables where any caching is too wide, gate with a via arm instead of a bare ctx.scope.<kind> arm: via emits 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 } }>;
  • requestField must equal every role-relationship's resource.column.
  • Each relationship's from table must exist and ship a generated firewall (it can't be exception).
  • A subKeys entry ending in [] is set-valued (lowers to inArray, minted as string[]); without the marker it's scalar (eq, minted as string).
  • subject defaults to 'user'. 'supplied'/'both' enable ctx.mintScope (sessionless mint); 'supplied' is not mintable via /scope/v1/enter.
  • ctx.mintScope({ via, subject }) mints a scope-only token (no user identity, ctx.userId undefined) from the from-row whose PK is subject — reading the role, resource id, sub-keys, and subjectId off that row.
  • subjectId is the conferring row's primary key, populated on both proof paths — scope self-data on ctx.scope.<kind>.subjectId.
  • q.scope('<kind>') auto-stamps the column from ctx.scope.<kind>.id on insert.
  • Generated route: POST /scope/v1/enter (mounted only when authz.scopes is declared).

On this page