Authentication & Authorization
Companions:
- Collections — lifecycle hooks (including
beforeRead/afterRead) live on the collection schema; this doc is the reference for the auth subsystem the hooks plug into. - Routing & API — server-fn transport that resolves
RequestContextand passes it down. - Relationships —
ReadContextis the seed for the actor-carryingRequestContext; populate threadsbeforeReadthrough to populated target collections. - Client SDK — the in-process SDK is where actor threading becomes externally visible.
Overview
Byline ships an end-to-end authentication and authorization subsystem with three load-bearing properties:
- Admin identity is a built-in subsystem, not a collection.
admin_users,admin_roles,admin_role_admin_user, andadmin_permissions(live names carry thebyline_prefix:byline_admin_users, …) are dedicated tables owned by@byline/admin(with the Postgres adapter in@byline/db-postgres/admin). Admin users are never localized, versioned, workflowed, or rendered by the collection runtime. - Two auth realms from day one: AdminAuth and UserAuth.
Actor = AdminAuth | UserAuth | null. Today onlyAdminAuthand thenull(anonymous public) case are used at runtime;UserAuthis reserved in the type union so the contract does not have to grow a discriminator later. - Service-layer enforcement, not transport-layer enforcement. Every gate runs inside
@byline/core/@byline/adminservices, so the same gate is active no matter which transport (admin server fn, in-process client, future stable HTTP) invokes it. Transport edges only resolve and passRequestContext.
The subsystem is split across two packages by concern:
Package | Role |
| Leaf package. Actor primitives ( |
| Concrete admin subsystem. Admin user / role / permission / account modules (each as |
Postgres-backed repositories ship as the @byline/db-postgres/admin subpath, plugged into AdminStore.
Quick reference
Each entry is the minimal shape for one task. The "Edit" line tells you which file you actually change; the link at the end points at the deeper architecture section.
1. Register a new ability
Abilities are flat dotted strings. Subsystems register them once at boot so the role-ability editor can enumerate them as a checkbox tree.
Edit: the registering module, e.g. packages/admin/src/modules/admin-users/abilities.ts for built-in admin abilities, or apps/webapp/byline/server.config.ts after initBylineCore() returns for app-level additions.
import type { AbilityRegistry } from '@byline/auth'
export function registerMyPluginAbilities(registry: AbilityRegistry) { registry.register({ key: 'plugins.myThing.read', label: 'Read my-thing', group: 'My Plugin', description: 'View my-thing records.', }) registry.register({ key: 'plugins.myThing.update', label: 'Update my-thing', group: 'My Plugin', description: 'Edit my-thing records.', })}Collection abilities (collections.<path>.{read,create,update,delete,publish,changeStatus,reindex}) are auto-registered by initBylineCore(). Only plugins outside the collection runtime need to register manually.
2. Assert an ability at a service-layer call site
Service-layer enforcement is the real boundary. UI cues are cosmetic. Every write entry point and every read entry point on @byline/client already routes through these helpers. You only call them directly when authoring a new service.
Edit: the new service file, e.g. packages/core/src/services/<your-service>.ts (collection scope) or packages/admin/src/modules/<module>/commands.ts (admin scope).
// Collection-scope service — gates `verb` on `collections.<path>.<verb>`.import { assertActorCanPerform } from '@byline/core/auth'
await assertActorCanPerform(requestContext, collectionPath, 'update')// Admin-scope command — always requires AdminAuth, asserts the named ability.import { assertAdminActor } from '@byline/admin'
const actor = assertAdminActor(requestContext, 'admin.users.create')3. Resolve RequestContext in a script or seed
Seeds, migrations, and one-off scripts need a RequestContext to call @byline/client or any service-layer entry point. Use createSuperAdminContext: the super-admin path is explicit in the code, never ambient.
Edit: apps/webapp/byline/seeds/<your-seed>.ts (or any script that imports byline/server.config.ts).
import { createSuperAdminContext } from '@byline/auth'import { getBylineClient } from '@byline/core'
const context = createSuperAdminContext({ id: 'seed:bootstrap' })const client = getBylineClient({ requestContext: context })
await client.collection('pages').create({ title: 'Hello world' })Inside admin server functions, use getAdminRequestContext() instead (see Actors and RequestContext).
4. Recipe — owner-only drafts
Anyone with read sees published documents. Authors additionally see their own drafts. Editors with a broader ability see everything.
Edit: the collection schema, e.g. apps/webapp/byline/collections/posts/schema.ts.
import { defineCollection } from '@byline/core'
export const Posts = defineCollection({ path: 'posts', fields: [/* … includes authorId */], hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.any')) return return { $or: [ { status: 'published' }, { status: 'draft', authorId: requestContext.actor?.id ?? '__none__' }, ], } }, },})The fallback '__none__' collapses cleanly when actor is absent: anonymous readers get the published-only branch.
→ Read-side scoping — beforeRead
5. Recipe — multi-tenant scoping
Every document belongs to a tenant. Every read clamps to the actor's tenant: full stop, no ability needed. Deny-by-default.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => ({ tenantId: requestContext.actor?.tenantId ?? '__none__', }),}Anonymous readers see nothing, because no tenant matches '__none__'. If a tenant has a public storefront, expose it through a separate collection or a dedicated published-and-public flag rather than relaxing this predicate. Tenant scoping should never have a forgotten escape hatch.
→ Read-side scoping — beforeRead
6. Recipe — embargoed visibility
Documents remain published but hidden from ordinary readers until a specific timestamp. Editors should still see them in preview.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.embargoed')) return return { publishAt: { $lte: new Date().toISOString() } } },}The predicate compares against publishAt at query time, so each request reads "now". Caching layers above this need to be cache-key-aware of time, or the embargo lifts late.
This recipe is a visibility embargo, not Byline's scheduled publication lifecycle. It does not change a document's workflow status, run publication hooks, or append a publication audit. Use the first-class scheduled publication operations when a draft must transition to published at a future instant.
Scheduled publication requires both collections.<path>.changeStatus and collections.<path>.publish when an editor schedules, reschedules, or re-confirms a document. Byline records that authorization in last_authorized_by and does not re-check the editor's current abilities when the recurring task fires. This retained-authorization policy means that disabling or deleting an admin account does not revoke schedules that the account already authorized. Account offboarding must therefore include reviewing the Scheduled Publications admin listing, filtering by that account ID, and cancelling any pending schedules that should no longer run. The publication itself still uses the normal status lifecycle, including workflow validation, hooks, automatic archival, and audit.
→ Read-side scoping — beforeRead
7. Recipe — soft-delete hide
Documents are soft-deleted by setting deletedAt rather than being removed from the table. Most readers never see them; an admin "trash bin" view opts in via an ability.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.posts.read.deleted')) return return { deletedAt: null } },}Pair with a delete collection method that performs the soft-delete write rather than a hard delete; otherwise the predicate has nothing to scope.
→ Read-side scoping — beforeRead
8. Recipe — department / workspace visibility
Each document is tagged with a department. Users may belong to multiple departments and see documents from any of theirs.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => ({ departmentId: { $in: requestContext.actor?.departmentIds ?? [] }, }),}When departmentIds is empty, $in: [] returns no rows: deny by default. If the actor's department list is loaded asynchronously, make the hook async; the same operation context is threaded through direct reads and populate.
→ Read-side scoping — beforeRead
9. Recipe — self-only on user-like collections
A profiles collection (or similar user-shaped data) where ordinary users may only see their own row, but staff with a broader ability see all rows.
Edit: the collection schema.
hooks: { beforeRead: ({ requestContext }) => { if (requestContext.actor?.hasAbility('collections.profiles.read.any')) return const profileId = requestContext.actor?.profileId return profileId ? { id: profileId } : false },}The reserved id key resolves to the logical document id. Returning false produces an empty result when no profile is associated with the actor. If your user model links profiles by a separate foreign key (e.g. userId rather than profileId === actor.id), filter on that field instead.
→ Read-side scoping — beforeRead
10. Recipe — split public and private singleton values
The base read gate permits an anonymous actor to read published documents. A beforeRead hook can restrict that access further. Keep publicly readable values and operational values in separate singletons, then deny anonymous reads on the private singleton.
Edit: the singleton schema files.
import { defineSingleton } from '@byline/core'
export const PublicSiteSettings = defineSingleton({ path: 'site-settings', label: 'Site settings', fields: [{ name: 'siteName', label: 'Site name', type: 'text' }],})
export const PrivateOperations = defineSingleton({ path: 'private-operations', label: 'Private operations', fields: [{ name: 'senderAddress', label: 'Sender address', type: 'text' }], hooks: { beforeRead: ({ requestContext }) => requestContext.actor == null ? false : undefined, },})false normalises to { id: { $in: [] } }, so anonymous reads return no document instead of throwing. If several beforeRead hooks are registered, Byline combines their results with logical AND; a later hook cannot override the denial.
An operational value placed in a public singleton is readable by an anonymous client, and nothing reports the modelling mistake. Separate private operational values before publishing the singleton.
→ Read-side scoping — beforeRead
11. Mask or redact a field on read (afterRead)
Field-level visibility (masking, hashing, omitting) lives in afterRead. The hook receives the materialised document and can mutate doc.fields in place; mutations propagate through the response.
Edit: the collection schema.
hooks: { afterRead: ({ doc, requestContext }) => { if (requestContext.actor?.hasAbility('collections.users.read.pii')) return if (doc.fields.email) { doc.fields.email = doc.fields.email.replace(/^([^@]).*@/, '$1***@') } },}afterRead fires after populate on the source document, so hooks see the fully populated tree. See Collections — Lifecycle hooks for the full hook contract.
→ Field-level redaction with afterRead
12. Bypass beforeRead (escape hatch)
Admin tooling, seeds, and migrations sometimes need to see everything regardless of scoping. The _bypassBeforeRead: true option on @byline/client read options is the deliberate, narrow exit.
Edit: the script or admin tool calling the SDK.
const allDocs = await client.collection('posts').find({ where: { status: 'draft' }, _bypassBeforeRead: true, // skip the beforeRead scoping predicate})Use only from internal tooling. Never inside application code paths: the whole point of beforeRead is to apply uniformly.
→ The documented escape hatches
13. Plug in a different SessionProvider
Sessions are pluggable behind SessionProvider. The built-in JwtSessionProvider is fully featured (15-min access, 30-day refresh, rotation, replay detection, argon2id), but Lucia, better-auth, WorkOS, Clerk, or institutional SSO can drop in by implementing the interface.
A provider implements the verification, sign-in, renewal, revocation and actor-resolution methods described in the session contract. The host consumes a stable sessionId returned by the provider; it does not parse native JWT claims or query the native login table. An IAM adapter can supply that identity and implement revocation using its own persistence or upstream service.
Return the same sessionId through renewal and a new identity for each fresh login. Verify both the credential and its current authority before returning an actor. Report access expiry distinctly with ERR_ACCESS_EXPIRED; invalid signatures, revocation and service failures must not be reported as expiry. Honor expectedSessionId during renewal and logout so an operation cannot adopt a different login. Implement observed-credential replacement revocation atomically, or explicitly document an unsupported capability rather than silently ignoring those arguments.
Register the adapter as sessionProvider in initBylineCore, alongside the application's admin store. External IAM integration does not require using Byline's native byline_admin_login_sessions table.
Architecture
Actors and RequestContext
type Actor = AdminAuth | UserAuth | null
class AdminAuth { readonly id: string readonly abilities: ReadonlySet<string> readonly isSuperAdmin: boolean hasAbility(ability: string): boolean assertAbility(ability: string): void // throws AuthError if missing assertAbilities(...abilities: string[]): void}
interface RequestContext { actor: Actor requestId: string locale?: string readMode?: 'published' | 'any'}RequestContext and ReadContext are separate objects. The client binds one authenticated request authority to a logical read operation, then gives hooks an operation-scoped request-context clone carrying the effective readMode. Populate, richtext target reads, beforeRead, afterRead, and ability checks therefore share one actor without mutating the configured context. Reusing that logical ReadContext with another authority fails closed rather than inheriting scope compiled for the first caller.
Three classes of caller construct RequestContext:
- Admin server functions call
getAdminRequestContext()(packages/client/src/server/admin-context.ts, exported from@byline/client/server). It reads the session cookie, callssessionProvider.verifyAccessToken, and attaches the resolvedAdminAuth. No actor → throws. - Public readers (the in-process
@byline/client) default toactor: null,readMode: 'published'. Anonymous access is permitted on read paths only when the read mode is'published'. - Scripts, seeds, and migrations call
createSuperAdminContext({ id })from@byline/auth. The fact that the caller is acting as super-admin is explicit in the code, not ambient, and every short-circuit onactor.isSuperAdmin === trueis auditable.
RequestContext is what every lifecycle service, populate call, hook, and SDK entry point receives. Auth populates the actor; access control reads it. Transport edges do not enforce.
Abilities
Abilities are flat dotted strings stored as varchar(128) in admin_permissions. Examples:
collections.pages.readcollections.pages.createcollections.pages.updatecollections.pages.deletecollections.pages.publishcollections.pages.changeStatuscollections.pages.reindexsingletons.site-settings.readsingletons.site-settings.updatesingletons.site-settings.publishsingletons.site-settings.changeStatusadmin.users.createadmin.roles.updateadmin.permissions.readadmin.activity.readThe flat-string choice is deliberate: it is what the role editor renders as a checkbox tree, what assertAbility checks, and what admin_permissions stores as one row per (role, ability) grant. CASL-style structured { subject, action } pairs were considered and rejected: they complicate the role editor without payoff at this scope.
The AbilityRegistry. AbilityRegistry (packages/auth/src/abilities.ts) is the single load-bearing abstraction. Every subsystem that wants to gate behaviour behind a permission registers its abilities at initBylineCore() time. Two consumers feed off it:
- Runtime —
assertAbility('collections.pages.publish')is a flat set-membership check onactor.abilities. The registry validates keys in dev mode (warns on unregistered keys); the check itself does not consult it. - Admin UI — the role-ability editor enumerates registered abilities, grouped by
group, as a checkbox tree. No hand-wiring per plugin.
Collections auto-contribute their abilities at registration time:
collections.<path>.{ read, create, update, delete, publish, changeStatus, reindex }Singletons contribute the cardinality-one family:
singletons.<path>.{ read, update, publish, changeStatus }There is no singleton create ability because update() materialises an empty slot under the same update permission used by later saves. There is no public delete operation, and no reindex ability because singleton search indexing is not shipped. The kind-aware namespace means that a stale or mistyped grant such as collections.site-settings.update cannot authorize the site-settings singleton.
@byline/admin registers its own abilities (admin.users.*, admin.roles.*, admin.permissions.*, and the read-only admin.activity.read that gates the system activity area) the same way, via register*Abilities() exports. Future plugins follow the same pattern: register at init time, assert at call sites. The core knows nothing plugin-specific while still rendering a complete admin UI.
Two-layer access control
Layer 1: flat abilities. Coarse-grained, table-stored, role-editable from the UI. Sufficient for "can this actor call this verb on this document resource at all." Asserted at the service-layer entry point.
Layer 2: conditional rules in hooks. Per resource, in code, with full access to the document and the actor. The hook machinery is where ownership, state-gated, locale-masked, and tenant-scoped rules live:
CollectionHooks.beforeRead— contributes aQueryPredicateAND-merged into the SQL query. Owner-only, tenant-scoped, soft-delete-hide.CollectionHooks.afterRead— observes the materialised document and the actor; can mask fields, redact values, or tag rows.CollectionHooks.beforeUpdate/ workflow transition hooks — gate writes on document state ("publish only ifstatus === 'in-review'").SingletonHooks.beforeSave/ workflow transition hooks — apply the equivalent write and workflow rules without exposing an internal create/update branch.
CASL's ideas (subject + action + conditions) are useful here; CASL itself is not adopted. CASL rules are code; flat abilities are data. Storing compiled CASL rules in a database and editing them from a UI was rejected as awkward at best.
The seven Quick Reference recipes above cover the common Layer-2 patterns end-to-end. The deeper mechanics of the hook itself are documented in Read-side scoping.
The enforcement boundary
UI cues (hiding buttons, disabling menu items) are cosmetic and explicitly untrusted. An attacker can call the server function directly, drive @byline/client from a script, or hit a future HTTP endpoint. The real boundary is the service layer: every caller is forced through it.
Two helpers, one per realm:
Helper | Realm | Location |
| Document collections |
|
| Admin user / role / permission management |
|
assertActorCanPerform (document collections). Policy:
- No
requestContext→ERR_UNAUTHENTICATED. actor: null→ permitted only whenverb === 'read'andreadMode === 'published'. Any other null-actor call throwsERR_UNAUTHENTICATED.- Otherwise →
actor.assertAbility('collections.<path>.<verb>'). ThrowsAuthErroron miss. actor.isSuperAdmin === trueshort-circuits the ability check.
Call sites:
- Every
document-lifecycle.*write entry point (createDocument,updateDocument,updateDocumentWithPatches,changeStatus,unpublishDocument,deleteDocument,restoreDocumentVersion,duplicateDocument,copyToLocale). field-upload.uploadField— uploads are effectively a write under collection scope, gated oncreateeven whenshouldCreateDocument: false. See File / Media Uploads.@byline/clienton every read path: ordinary finds, collection and zone search, status counts, history/version reads, audit-log access gates, document-tree reads, relation populate, and richtext target hydration.- Every admin webapp document-collection server fn (
packages/host-tanstack-start/src/server-fns/collections/). Writes threadrequestContextintoDocumentLifecycleContext; ordinary reads delegate to the adminBylineClientso the same collection and row gates run.
assertAdminActor (admin management). Policy:
- Always requires a present
AdminAuthactor, no anonymous path. - Asserts the specific module ability:
admin.users.*,admin.roles.*,admin.permissions.*,admin.activity.read.
Called inside every *Command in @byline/admin/admin-{users,roles,permissions,account}. The transport wrappers (the matching server fns under packages/host-tanstack-start/src/server-fns/admin-{users,roles,permissions,account}/) carry no policy: they resolve RequestContext and delegate. The exception is admin.activity.read: the activity area owns no AdminStore command (it reads the document db adapter's findAuditLog directly), so its assertAdminActor call lives in the host server fn getSystemActivityLog rather than in an @byline/admin command.
The documented escape hatches
Two intentional bypasses exist, each on a single, well-marked seam:
- db.commands.* / db.queries.* direct calls bypass both helpers. Reserved for seeds, migrations, and internal tooling that need to bootstrap the system without an actor.
- _bypassBeforeRead: true on
@byline/clientread options skipsbeforeReadpredicate application. Reserved for the same class of caller: admin tooling that needs to see everything regardless of scoping rules.
These are deliberate, narrow exits. There is no ambient bypass and no environment variable.
Sessions — SessionProvider interface
Sessions are pluggable behind SessionProvider (packages/auth/src/session-provider.ts). The interface accommodates Lucia, better-auth, WorkOS, Clerk, institutional SAML/OIDC, or anything else that fits the contract; teams can run Byline end-to-end without reaching for any third-party identity service, because the built-in JwtSessionProvider is a fully capable first option, not a stub.
The exported types define the full contract, including token expiry dates:
import type { AdminAuth, RefreshSessionArgs, SessionProviderCapabilities, SessionTokens, SignInResult, SignInWithPasswordArgs,} from '@byline/auth'
interface SessionProvider { signInWithPassword(args: SignInWithPasswordArgs): Promise<SignInResult> verifyAccessToken(token: string): Promise<{ actor: AdminAuth; sessionId: string }> refreshSession(args: RefreshSessionArgs): Promise<SessionTokens> revokeSession(args: { refreshToken?: string; accessToken?: string; expectedSessionId?: string }): Promise<void> resolveActor(adminUserId: string): Promise<AdminAuth | null> readonly capabilities: SessionProviderCapabilities}SessionTokens contains sessionId, both credentials, and their expiry dates. SignInWithPasswordArgs can carry the access and refresh credentials observed on the sign-in request for replacement revocation. These are credentials, not client-supplied login IDs. RefreshSessionArgs carries the refresh token and optional expected login identity.
The capability flags are how the admin UI decides which affordances to render: a provider without passwordChange hides the password-change form rather than failing the call.
Built-in JwtSessionProvider (packages/admin/src/modules/auth/jwt-session-provider.ts and friends):
- 15-minute access tokens. Verification checks the current account session generation (
sv) and login validity (sid) on every request; neither overrides the other. Password changes/resets and disablement invalidate older generations immediately after commit. - 30-day refresh tokens stored in
admin_refresh_tokensfor revocation. DB-backed rather than short-lived-only, because short-lived-only would have no way to force-sign-out a compromised account. - Rotation on every refresh. The old refresh token is invalidated when a new pair is issued.
- Replay detection. Reusing a rotated refresh token revokes its login, immediately rejecting its access tokens and renewal. An uncoordinated legitimate collision receives the same fail-closed outcome and may require fresh sign-in. Login membership removes traversal limits from revocation.
- argon2id password hashing (
packages/admin/src/modules/auth/password.ts). The full PHC string is stored inadmin_users.password.
resolveActor(adminUserId) joins admin_role_admin_user → admin_permissions → flat ability strings to build the runtime AdminAuth.
Password changes and account disablement
Native password changes and administrator resets atomically advance admin_users.session_version and revoke all refresh sessions. Disablement does the same, including through a general user update. Re-enabling the account does not restore old access or refresh sessions. Edit revisions (vid) remain separate from authentication generations.
The built-in provider checks the JWT sv claim against the current account generation. Refresh also checks account enablement and the generation stored on its row. Sign-in and refresh acquire an account row lock before issuance; password verification runs outside that lock and sign-in revalidates the observed credentials and generation inside it. Account mutation and refresh writes commit or roll back together. An operation already authorized with a request-scoped actor is not retroactively cancelled.
After a successful self-service password change, the TanStack host clears session and preview cookies and the form shows confirmation and a Sign in button that reloads the protected page to request a fresh sign-in. External identity-provider sessions are outside native repository revocation; custom providers must implement their own session policy.
Upgrades require the session-generation SQL script for the chosen adapter. Stop old instances before applying it, then restart every instance with the new provider. Access JWTs without sv and legacy refresh rows with generation -1 are rejected, so existing users sign in again. Do not mix old and new provider instances during rollout.
Explicit renewal and session changes
getAdminRequestContext() only verifies credentials. Business requests and SSR never rotate tokens or write or clear session cookies. The TanStack host's pre-handler middleware compares the page's expected login identity with the verified session before invoking a protected business handler. Only expiry detected at that boundary allows one resend after explicit CSRF-protected renewal. Handler failures and uncertain network results never permit automatic replay.
A module-level promise coalesces same-tab renewal. Authentication cookie writers share a queue, with Web Locks extending coordination across supporting tabs. Renewal with already-valid access is a no-op, avoiding a second rotation after a delayed expiry response. BroadcastChannel notifications carry no credentials. When the access credential has expired at navigation time, recovery depends on where the admin layout guard runs. On a browser-side navigation, the guard performs the coordinated renewal in place and retries the load once, so the user stays on the requested URL. On a server render, the guard cannot renew, and a server-side route error would surface as an HTTP 500, so it redirects to the sign-in route; that route's bootstrap performs the same renewal and returns to the requested location. Persistent refresh credentials preserve sign-in across ordinary browser restarts.
Public pages follow the same rule from the browser side. Public reads never rotate credentials, so a signed-in editor whose access credential has expired renders a public page as an anonymous visitor: the admin bar hides and preview mode drops. The getCurrentAdminSessionSoft server function reports renewable: true when the access credential has expired and a refresh credential is present. The AdminSessionRecovery component from @byline/host-tanstack-start/integrations/admin-session-recovery, mounted in the public layout with that hint, runs the same coordinated renewal and then re-runs the route loaders, so the admin bar and any draft-aware reads on the page re-evaluate. Content already rendered without preview authorization is not changed retroactively; the loaders re-run after renewal. A terminal outcome such as a revoked login suppresses further attempts until the page reloads.
A changed login blocks the page behind an acknowledgement screen. The browser retains the login expected after successful sign-in across navigation. Acknowledgement checks the active account again and reloads the page, discarding queued work. A late response can still physically replace fixed-name credential cookies; overlapping sign-ins are an accepted detected residual, not a guarantee of cookie-write ordering. The server rejects operations bound to a different login even without cross-tab notifications.
Logout revokes the login identified by verified access credentials or a known refresh member before returning success. When the presented credentials identify no login, logout succeeds idempotently and clears the browser credentials without asserting that a revocation write occurred. The UI reports an unconfirmed logout as a failure. Successful replacement sign-in atomically revokes observed old logins, including across accounts, while preserving unrelated device logins. Failed revocation rolls back replacement issuance. The native provider's token-free onEvent hook reports attempted, completed and contested refreshes; collect a denominator before assessing collision frequency.
Apply the account-generation and login-state migrations together with a stopped-instance upgrade. Legacy credentials lacking sv or sid require one combined fresh sign-in. Sliding refresh expiry remains; there is no monthly absolute lifetime. The implementation still requires combined R2/R3 review before release.
Password sign-in protection
The TanStack host requires ServerConfig.passwordSignIn and passwordSignInMiddleware for its password sign-in endpoint. The protection runs before the configured provider verifies a password. Direct calls to a provider outside the host endpoint must arrange their own admission limits.
In src/start.ts, register the request middleware after CSRF protection. It bounds the serialized sign-in body to 16 KiB before TanStack deserializes it, checks actual streamed bytes even when Content-Length is absent or incorrect, and gives body reading five seconds to complete. Only JSON POST requests are accepted for sign-in; other server functions, including uploads, retain their own transport policies.
import { createCsrfMiddleware, createStart } from '@tanstack/react-start'import { passwordSignInMiddleware } from '@byline/host-tanstack-start/integrations/sign-in-middleware'import { sessionRequestMiddleware } from '@byline/host-tanstack-start/integrations/session-request-middleware'import { bylineCodedErrorAdapter } from '@byline/host-tanstack-start/integrations/start-errors'
export const startInstance = createStart(() => ({ serializationAdapters: [bylineCodedErrorAdapter], requestMiddleware: [ createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === 'serverFn' }), sessionRequestMiddleware, passwordSignInMiddleware, ],}))The shared passwordSignInSchema from @byline/core/validation accepts nonempty passwords up to MAX_PASSWORD_LENGTH (128 JavaScript string code units), without imposing current password-creation complexity rules on existing credentials. It bounds email input to 254 code units before trimming and lowercasing it. Passwords are never trimmed. The built-in provider also applies this schema to direct calls.
Configure admission and client-IP resolution in your server configuration:
import type { AdminStore } from '@byline/admin'import { createPasswordSignInLimiter } from '@byline/admin/auth'import { getRequestIP } from '@tanstack/react-start/server'import { createClientIpResolver } from '@byline/host-tanstack-start/integrations/client-ip'
export function passwordSignInProtection(store: AdminStore, signingSecret: string) { return { limiter: createPasswordSignInLimiter(store.signInRateLimits, signingSecret), resolveClientIp: createClientIpResolver({ trustedProxyHeader: process.env.BYLINE_TRUSTED_CLIENT_IP_HEADER || undefined, resolvePeerIp: () => getRequestIP() ?? null, }), }}Assign the helper's result to passwordSignIn alongside sessionProvider when calling initBylineCore(), and include protection.limiter.cleanupTask in recurringTasks. For example, first create const protection = passwordSignInProtection(adminStore, signingSecret), then configure passwordSignIn: protection and recurringTasks: [protection.limiter.cleanupTask], preserving other registered tasks. Start startBylineScheduler(core) in the host server entry, or arrange external runDueTasks(core) invocations for hosts that suspend or scale to zero. Configuration imports never start timers or database cleanup. Core boot rejects missing registration of the built-in limiter's required cleanup task and adapters without scheduler support. Registration alone does not prove that a runner has started; check the scheduler's task health after deployment. Custom limiters with native TTL expiry may omit requiredCleanupTask. Apply the new Drizzle migration in development, or packages/db-postgres/sql/0011_add-sign-in-rate-limits.sql / packages/db-mysql/sql/0006_add-sign-in-rate-limits.sql for an existing production installation, before enabling this configuration. Both adapters add byline_admin_sign_in_rate_limits and expose adminStore.signInRateLimits.
The default limiter uses two shared, fixed-window budgets:
Scope | Budget |
Normalized email plus client network, including unknown accounts | 10 attempts per 15 minutes |
Client network (IPv4 address or IPv6 /64) | 60 attempts per minute |
Network admission precedes account-plus-network admission. Exhausting one network does not lock that account out on other networks. The full normalized address remains available for session metadata. Successful sign-ins consume budgets; they do not clear counters. Fixed windows permit up to twice a budget across a boundary. Supply a complete SignInRateLimitPolicy as the factory's third argument to tune these limits. Application instances sharing a database must use the same policy and synchronized clocks.
This deliberately trades account-wide resistance to distributed guessing for availability: 1,000 independent networks can receive 10,000 attempts against one account per 15-minute window. Password hashing cost, process capacity, deployment-level traffic controls, and monitoring must address that residual risk. There is no installation-wide fixed-window counter.
Instantiate the limiter once per process. The host calls acquire() before accessing counters and releases the slot in finally after verification. Defaults allow one active operation and four queued requests, with a 250 ms queue timeout. A slot remains occupied for at least 100 ms, even after a fast denial. This minimum duration bounds counter creation as well as outstanding password work; it is not a worker pool and does not move Argon2 off the main thread. Full queues and timed-out waiters return 429 with Retry-After: 1. The default capacity ceiling is approximately 600 operations per minute per process, or 2,400 across four processes, rather than the earlier shared 120/minute proposal. Multiple processes have independent CPU capacity, so N processes have N times the slots. Actual password-verification throughput can be lower because hashing and database work occupy the slot. Slow or hung providers retain their slots; sustained attackers can still occupy available capacity.
Each slot can create at most two counter rows. At the defaults, one process therefore creates at most approximately 1,200 rows per minute (plus the initial slot burst). The adapters increment counters atomically and cap saturated counts. Every admitted request also purges up to 100 expired rows before checking counters. This load-proportional cleanup is retained: its deletion allowance scales with traffic across processes, with up to 50 deleted rows per newly created row. That is capacity to remove eligible rows, not a guarantee that rows are old enough or that database work completes promptly.
The limiter exposes cleanupTask, a recurring task named auth.sign-in-counters.cleanup. Its interval and lease are 60 seconds. A scheduler run purges at most 32 batches of 100 rows, renews its lease between batches, checks for cancellation, and reports workRemaining if it reaches the batch limit. Failures propagate into scheduler health and retry backoff. The scheduler coordinates execution across instances, so this 3,200-row allowance is installation-wide per run; it is an idle-backlog drain, not the component that tracks ongoing creation. It does not run immediately at configuration evaluation. A large idle backlog can take multiple scheduler runs to clear.
Rows become eligible five minutes after their window expires. Cleanup latency affects retained storage only: window-scoped keys determine admission without depending on deletion of old rows. These are creation-rate and cleanup-work bounds, not an absolute row cap or exact residency estimate. Outages, suspended runners, instance churn, and custom policy settings can increase retained rows. Monitor task health and database size, and increase the idle drain allowance if measured backlog warrants it. Call the limiter's dispose() when replacing it or shutting down its host to reject outstanding queue waiters; scheduler lifetime remains separately owned by the host.
The factory's fifth argument accepts SignInLimiterOptions, including onEvent, slots, maxQueue, queueTimeoutMs, and minimumSlotMs. onEvent receives admitted/denied attempts, capacity shedding, counter/cleanup errors, and verification success/failure. Stable HMAC-SHA-256 account digests correlate attempts across networks; network digests correlate spraying. The required second factory argument is a shared installation secret of at least 32 bytes. The reference configurations reuse the JWT secret through a domain-separated HMAC derivation; a dedicated installation secret is also supported. All instances must use the same secret. Rotating it resets counter identities and breaks historical event correlation, so coordinate rotation across instances. Existing rows still expire normally; no schema change is required. Neither events nor counter keys include passwords, raw emails, or raw IPs. These digests are pseudonymous rather than anonymous, but log or database access alone does not enable offline dictionary recovery without the installation secret. Wire this synchronous, nonblocking hook to your existing metrics or buffered logger and alert on failures across many networks for one account. Hook errors cannot change authentication outcomes. Without a hook, diagnostic warnings are sampled by event type; they are a fallback, not a distributed detection service.
Denied admission returns HTTP 429 with Retry-After. Missing protection, unavailable client identity, or counter failures return HTTP 503. MySQL retries a transaction at most twice after an explicit InnoDB deadlock-victim error (1213); it does not retry ambiguous connection/commit failures. The sign-in form retains its generic failure message.
Client-IP trust and deployment
createClientIpResolver either reads a direct peer address through the supplied resolver or reads one explicitly configured proxy header. It validates addresses, canonicalizes IPv6 spellings and IPv4-mapped IPv6, and rejects address lists. It does not implicitly trust X-Forwarded-For, X-Real-IP, or any other forwarding header.
Setting BYLINE_TRUSTED_CLIENT_IP_HEADER is an operator assertion: the reverse proxy must overwrite that header, and clients must not be able to bypass the proxy. Validation proves only that a value is an IP address. It cannot prove the address belongs to the caller. Leaving the header unset uses the direct peer, which may be a proxy shared by many users. Inspect the actual deployment before choosing the mode. A host with another trusted platform identity source can supply its own ClientIpResolver.
The reference configuration groups requests without peer metadata under 127.0.0.1 only when NODE_ENV is development, to support Vite. Production has no such fallback. Account-plus-network limits depend on correct client-IP resolution; they do not make a misconfigured proxy trustworthy. Edge request limits remain appropriate to protect the application and database from traffic that exceeds what application admission can handle.
The tests cover credential bounds, body streaming, forwarding-header spoofing, normalized identities, fail-closed admission, bounded queues, idle cleanup, and concurrent database consumers in the shared adapter conformance suite. pnpm --filter @byline/webapp test:sign-in-transport runs a real Start HTTP server with substituted application services; CI runs it to check Request identity, body enforcement, and CSRF ordering. This protection does not change password-reset session revocation or concurrent refresh-token rotation; those remain separate session-lifecycle work.
Read-side scoping — the beforeRead hook
CollectionHooks.beforeRead is the query-level access-control surface.
beforeRead?: (ctx: { collectionPath: string requestContext: RequestContext readContext: ReadContext}) => QueryPredicate | false | void | Promise<QueryPredicate | false | void>The hook runs for collection and singleton reads and for target collections reached through relation or richtext population. It receives the actor and read context and returns a QueryPredicate, false, or void. Caller where and hook security predicates compile separately: caller input uses the ordinary parser, while the hook uses strict validation and compiles wholly to adapter DocumentFilters. Those two filter lists are then ANDed. Callers never see the scope: it is invisible, query-level, and applies even when no where was specified. Returning void (or undefined) means "no scoping for this actor", typically the admin / superuser path. Returning false denies every row and produces an empty result rather than throwing.
The predicate language is the security-checked subset of WhereClause: scalar equality, $eq / $ne / $gt / $gte / $lt / $lte / $contains / $in / $nin, relation sub-clauses and quantifiers, and $and / $or combinators. Field names resolve through field-store-map. Reserved id, status, and path keys compile as document-column filters at the correct relation depth. For top-level security clauses, status accepts $eq / $ne / $in / $nin; path accepts those plus $contains. Because strict security compilation no longer passes through caller-only top-level scalar handling, these operators are enforced consistently on list, detail, populate, count, history, version, and tree reads.
The strict result is compiled once per logical ReadContext + client security domain + collection definition + effective read mode and held in private state bound to that request authority. The authority includes request id, locale, actor realm/id, super-admin state, and abilities; changing any of them fails closed. Promise sharing prevents concurrent populate/richtext branches from rerunning hooks or relation-id resolution, while same-collection and cyclic hook reads fail with ERR_READ_RECURSION instead of awaiting their own compilation. Callers cannot access or seed the private authorization cache.
Wired into:
- Every
@byline/clientread entry point, including search finishing, historical reads, counts, and document-tree reads. - Authenticated
populateDocumentscalls and richtext target hydration, before target adapter access.
Composition rules:
- Hook predicate AND user where. The adapter applies the separately compiled filter lists with implicit AND. A user passing
where: { status: 'draft' }against Recipe 1 (owner-only drafts) sees only their own drafts: both clauses apply. - Compilation stays separate. Permissive caller parsing cannot weaken, drop, or pre-seed strict hook filters; only the final adapter filter lists are combined.
- void means "no scoping". Use it for the superuser / unconditional-read branch. Do not return an empty object
{}; strict security-predicate parsing rejects it. - Return false to deny every row. Byline normalises
falseto{ id: { $in: [] } }, which remains a valid explicit form. The predicate compiles to an always-false condition without asking the database to cast a sentinel string to UUID; throwing would collapse the endpoint instead of producing the natural empty result. Multiple hook results combine with logical AND, so a later hook cannot weaken a denial. - Security predicates are strict and fail closed. Unknown fields or operators, empty predicates/combinators, unsupported
query, unresolved relation targets, and malformed reserved-column operands raise a validation error instead of being silently discarded. The deliberate exception is a relation quantifier such as$none: {}, whose empty nested clause means "no resolving targets." - Bypass is explicit. Admin tooling, migrations, and seeds pass
_bypassBeforeRead: trueon the read options to skip the hook. This is a deliberate escape hatch and should never be used inside application code.
What beforeRead is not for:
- Field-level redaction. Use
afterReadto mutatedoc.fields(see the next section).beforeReadis row-level only. - Computed-field filters. The predicate compiles against EAV store columns and the reserved document keys
status,path, andid. Synthesise a real field if you need to filter on something derived. - Write-side checks.
assertActorCanPerformalready gates every write path. Don't try to enforce mutation rules from a read hook.
The client-before-read.integration.test.ts suite in packages/client/tests/integration/ wires the owner-only-drafts and multi-tenant recipes end-to-end and serves as the executable companion.
Field-level redaction with afterRead
afterRead is the materialised-document hook. It runs on documents returned by @byline/client, including historical versions, tree hydration, relation targets, and richtext targets. The hook receives the document and the authenticated request context; mutations to doc.fields propagate back through the response. Recursive re-entry into an actively processing version fails closed rather than returning a potentially half-redacted object.
afterRead?: (ctx: { doc: Record<string, any> collectionPath: string requestContext: RequestContext readContext: ReadContext}) => void | Promise<void>Typical patterns:
- Mask — replace a value with a placeholder (
email→j***@example.com). - Redact — delete the key entirely.
- Hash — replace with a deterministic non-reversible value.
- Tag — add a synthetic field marking the row's visibility class.
afterRead runs after populate on the source document, so hooks observe the fully populated tree. Hooks that perform their own reads must thread readContext back through (client.collection(…).find({ _readContext: readContext })) so recursion limits and the authenticated operation stay coherent.
See Collections — Lifecycle hooks for the broader hook surface (create / update / delete / status-change / unpublish), and Quick Reference recipe 10 for a worked masking example.
Admin UI surface
Repository route files live under apps/webapp/src/routes/_byline/<configured-admin-segment>/; for the default routes.admin: '/admin', that is apps/webapp/src/routes/_byline/admin/. The page-level routes are thin shells that call into route factories from @byline/host-tanstack-start/routes, so the admin UI is reusable across host installations. When the configured segment changes, the filesystem route subtree and every route-factory ID must change with it.
Admin/sign-in route safety contract. routes.signIn must be outside the configured admin and API trees. An unauthenticated admin layout redirects to configured routes.signIn with the original location in callbackUrl; a browser-side navigation whose access credential has merely expired renews in place first and redirects only when that renewal fails. The sign-in host accepts that untrusted value only when it is an unencoded root-relative path inside the configured admin subtree. It preserves a valid query string and hash, but rejects external or protocol-relative URLs, backslashes, control characters, encoded-path tricks, and paths outside the admin subtree. Rejected or missing callbacks fall back to the configured admin dashboard. Successful sign-in uses full-page navigation so the admin layout re-runs against the new session cookies.
Host integrations pass the already-validated destination to SignInForm as redirectTo. The callbackUrl URL search parameter remains the host route's input, but it is not a SignInForm prop. Configure the canonical sign-in route through routes.signIn; createAdminLayoutRoute() reads that value directly.
Area | Capability |
| Password sign-in via |
| Self-service profile + password change. |
| List / create / edit / enable / disable admin users; assign roles; set password. |
| List / create / edit / reorder admin roles; member assignment. |
| Read-only inspector: registered abilities, role-ability matrix, who-has-what lookup. |
| Per-collection list / create / edit / history / status. Standard CMS surface. |
The role-ability editor (under roles/) is the primary control-plane UI: a checkbox tree driven by listAbilities(), grouped by ability group. Every checkbox toggle round-trips through admin-permissions.setRoleAbilities (gated on admin.permissions.update).
The permissions/ inspector is read-only by design: it surfaces what is registered and who holds it, but never edits. File-based config stays primary for anything schema-shaped (collections, fields, workflows, registered abilities). Drupal's structural mistake (making every schema-shaped decision live-editable from the UI) fragmented its source of truth between database rows and config files. Byline holds the line: file-based config is primary, the UI is an inspector for registered state, and only genuinely runtime concerns (feature flags, SMTP, branding) are ever live-editable.
UI ability cues (hiding Create / Publish / Delete buttons, disabling menu items) are cosmetic. The useAbility() hook and <RequireAbility> wrapper exist for UX, not security. The real gates run in the service layer per assertActorCanPerform and assertAdminActor.
Data model
Tables below are shown unprefixed for readability. Live names carry the byline_ prefix (byline_admin_users, byline_admin_roles, …) per the Postgres adapter's namespacing convention. See packages/db-postgres/src/database/schema/auth.ts.
admin_users id uuid pk vid uuid -- version id given_name text family_name text username text unique email text unique password text -- argon2id PHC string remember_me boolean last_login timestamptz last_login_ip inet failed_login_attempts int is_super_admin boolean is_enabled boolean is_email_verified boolean preferred_locale varchar(16) -- nullable; admin interface -- language for this editor. -- See docs/Internationalization created_at, updated_at timestamptz
admin_roles id uuid pk vid uuid name text machine_name text unique description text order int created_at, updated_at timestamptz
admin_role_admin_user admin_role_id uuid fk → admin_roles admin_user_id uuid fk → admin_users primary key (admin_role_id, admin_user_id)
admin_permissions id uuid pk admin_role_id uuid fk → admin_roles ability varchar(128) -- flat dotted string created_at, updated_at timestamptz unique (admin_role_id, ability)
admin_refresh_tokens -- JwtSessionProvider only id, admin_user_id, token_hash, issued_at, expires_at, revoked_at, replaced_by, ...admin_users.is_super_admin === true short-circuits all ability checks at runtime: a super-admin's AdminAuth carries every registered ability synthetically. The flag is not a substitute for granting abilities to roles; it is the bootstrap and break-glass mechanism.
The seed under apps/webapp/byline/seeds/admin.ts creates one super-admin user and one super-admin role on a fresh install.
UserAuth tables are reserved but not designed. The Actor union declares the type so the contract does not have to grow a discriminator later.
Architectural rules
- Service-layer enforcement, not transport-layer enforcement. Auth gates live inside
@byline/core/@byline/adminservices. Transport edges (admin server fns, future HTTP endpoints) only resolveRequestContextand pass it down. This keeps the same gate active no matter which transport invokes the service. - Flat abilities are the contract. Plugins register abilities; the role editor enumerates them;
admin_permissionsstores them as rows. Conditional rules live in hooks, not in the database. - actor: null is a first-class case. Anonymous public readers are explicitly modelled. The null actor is permitted on
readwithreadMode: 'published'and rejected everywhere else. - Super-admin is explicit in the code, not ambient. Migration scripts and seeds call
createSuperAdminContext({ id }); there is no environment variable, no test-mode bypass, no implicit "internal call" exception. - Reads go through @byline/client. Even from the admin webapp. This keeps
beforeRead/afterReadorchestration uniform with future external readers and means access-control predicates apply once, in one place. - The admin UI is an inspector, not a control panel for schema. File-based configuration is primary. Genuinely runtime settings (feature flags, SMTP) are fine to live-edit; collection schemas, field types, and workflow definitions are not.
Explicitly deferred
The following are declared in the contract but not implemented, kept that way deliberately so the surface does not have to grow a discriminator when they land:
- UserAuth sign-in surface. The type is in the
Actorunion; the DB tables, sign-in flow, and admin UI wait for a concrete end-user feature. - Magic-link / SSO / OIDC providers.
SessionProvideraccommodates them; built-in adapters wait for real demand. - UI-editable conditional rules (CASL-style). Hooks remain the expression surface. Revisit if real workloads demand role-editable conditional rules.
Code map
Concern | Location |
Actor primitives |
|
|
|
|
|
|
|
|
|
Document-collection enforcement |
|
Admin-management enforcement |
|
|
|
|
|
Predicate compiler |
|
Admin user / role / permission services |
|
Built-in JWT session provider |
|
Admin store aggregate |
|
Postgres admin repositories |
|
Admin schema + migration |
|
Admin server-fn auth context resolver |
|
Admin server fns (auth) |
|
Admin server fns (management) |
|
Admin route factories |
|
Route config normalization |
|
Admin paths + callback validation |
|
Canonical sign-in path |
|
Sign-in form redirect validation |
|
Admin UI route shells |
|
Super-admin seed |
|
Integration test for |
|