Using Auth
Sign-in flows, session handling, and organization context in the Quickback Stack
This page covers how to use Better Auth in your Quickback Stack application — sign-in flows, session management, and working with organization context.
Sign-In Flows
Better Auth supports multiple sign-in methods:
- Email/Password — Traditional credential-based authentication
- Email OTP — One-time passwords sent via email
- Magic Links — Passwordless email links
- Passkeys — WebAuthn biometric/hardware key authentication
Session Handling
Sessions live in the auth database. The signed cookie cache (session.cookieCache) is on by default with a 300 s maxAge: the auth middleware serves most requests from the cookie instead of the session + user lookups, which are otherwise two statements on every authenticated request (on Postgres over Hyperdrive, most of the fixed per-request cost). The trade is revocation latency: a ban, sign-out-everywhere or role change can take up to maxAge to reach a cached session. Bump cookieCache.version to invalidate every cached session at once, lower maxAgeSeconds, or set session: { cookieCache: { enabled: false } } when every request must see the row. The compiler does not configure Better Auth secondaryStorage, so a KV namespace does not cache sessions.
providers: {
auth: defineAuth("better-auth", {
session: {
cookieCache: { enabled: true, maxAgeSeconds: 60 }, // or { enabled: false }
},
}),
}The generated auth middleware resolves the session once per request and hydrates a single Hono context variable, ctx — it does not set session or user variables:
// Access the auth context in a Hono route
app.get('/api/me', async (c) => {
const ctx = c.get('ctx');
if (!ctx.authenticated) return c.json({ error: 'Unauthorized' }, 401);
return c.json({
userId: ctx.userId, // Better Auth user id
user: ctx.user, // { id, email, name }
roles: ctx.roles, // every role the caller satisfies (hierarchy-expanded, plus tier roles)
orgRole: ctx.orgRole, // the raw membership role: 'owner' | 'admin' | 'member' | custom
});
});ctx also carries authMethod ('session' | 'jwt' | 'api-key' | 'oauth' | 'principal'), userRole (the global user-table role), activeTeamId, and scope. In action and CRUD handlers this same object arrives as the ctx argument — you rarely call c.get('ctx') directly.
Application Profiles
Treat the Better Auth user row as login identity, not automatically as the
application's complete client or customer model. Before defining feature
tables, decide whether signed-in people need domain data beyond the identity
already available on ctx.user—for example a customer number, onboarding
state, preferences, billing metadata, clinical details, or relationships that
other feature records must reference.
If the auth identity fields are enough, use ctx.userId / ctx.user directly
and do not create an empty duplicate profile. If the product needs domain data
or relationships, create a first-class feature resource such as customers,
clientProfiles, or patientProfiles. Give it its own feature-table primary
key and normal access, firewall, masking, and guard rules.
Use userId: q.text().required().index() for the basic link to the Better Auth
identity. Quickback emits the physical user_id column. Treat it as a plain
text logical foreign key: a self-create/bootstrap action derives it from
ctx.userId, and generic clients must not supply or update it through
guards.createable / guards.updatable. If the model permits one profile per
identity globally, add unique: [{ columns: ["userId"] }]; if it permits one
per organization, use unique: [{ columns: ["organizationId", "userId"] }]
instead. Declare explicitly when userId is also the row-ownership firewall
column rather than relying on its name.
Do not write .references(() => user.id) or .references(() => users.id) on
that link. With the default split-D1 provider, Better Auth lives in AUTH_DB
and feature resources live in DB, so SQLite cannot enforce a cross-database
foreign key. A provider may place both stores in one physical database, but the
auth schema remains compiler-owned rather than an authored feature-table
import; keeping this link logical makes the definition portable. Other feature
tables should use a real feature-database FK such as
profileId: q.text().required().references(() => profiles.id) so the CMS can
resolve the profile label. See Schema → References,
Firewall → ownerId vs userId, and
Start patterns → Referencing users.
Organization Context
When organizations are enabled, the active organization is on the auth context as activeOrgId:
const orgId = c.get('ctx').activeOrgId;`activeOrgId`, not `activeOrganizationId`
activeOrganizationId is Better Auth's session field name (session.session.activeOrganizationId). Quickback canonicalises it to ctx.activeOrgId; firewall rules written against ctx.activeOrganizationId are rewritten to ctx.activeOrgId at compile time.
Listing organization members
Better Auth's GET /auth/v1/organization/list-members needs a session — a JWT bearer gets 401. To list the active organization's members on the JWT, opt into the roster read:
export default defineConfig({
// ...
auth: {
organizationRoster: { access: { roles: ['member', 'admin'] } },
},
});GET /auth/v1/organization/roster then returns { data: [{ id, name, email, image, role }] } for ctx.activeOrgId:
- 401 with no credentials, 403
ACCESS_NO_ORGwith no active organization, 403 when the caller holds none ofaccess.roles. - It reads on the service lane and filters to the active organization in the query, because Postgres RLS lets a caller-scoped handle read only their own
usersrow. - The column set is fixed. Sessions, accounts and credentials are never returned.
rolesis required, and the organization plugin must be enabled — otherwise the compile fails.
Changing users from actions — ctx.auth
Action code that bans a user, changes a member's role, deletes an account, or lists an organization's members uses ctx.auth — typed, bound to the caller's active organization, written through Better Auth's adapter, and stamping the qbrev:user revocation entry so outstanding JWTs die. Never write the auth tables through authDb. Full reference: ctx.auth.
Rows addressed to an email before the account exists (invitations, guest lists) can be linked automatically when the account verifies that email: auth.claims.
Related
- Auth Overview — Setup and configuration
- Auth Plugins — Available plugins
- Auth Security — Security best practices