Quickback Docs

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:

quickback.config.ts
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_ORG with no active organization, 403 when the caller holds none of access.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 users row.
  • The column set is fixed. Sessions, accounts and credentials are never returned.
  • roles is 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.

On this page