Session outcomes
Password sign-in can return a Session, a pending two-factor challenge, an unverified email, a Ban or a refusal. Treat those as distinct outcomes. session-outcome.ts and two-factor-outcome.ts provide the shared interpretation; hooks own navigation and error presentation.
The server registers two-factor, admin, organization, passkey, Expo and MCP OAuth plugins. Email verification policy comes from AUTH_SKIP_EMAIL_VERIFICATION; setup makes the intended policy explicit. Server reads use Better Auth's signed Session snapshot (at most 60 s old) or the database; sensitive operations always read the database (see Session reads).
Session reads
The session row in the database is the authority for who is signed in. Better Auth's cookie cache (session.cookieCache: compact, signed with HMAC, maxAge 60 s) keeps a snapshot of the session and user in the better-auth.session_data cookie (__Secure- on https). Server reads use that snapshot while it is valid, so most requests read no session from the database (ADR-0015).
| Read | Source |
|---|---|
The browser's presentation read (GET /api/auth/get-session): the /_auth guard, useSession, tab focus | The snapshot while it is younger than 60 s; otherwise the database, which re-issues it |
SSR guard and prefetches, every oRPC procedure, the files gateway (getRequestSession, once per request) | The same snapshot; otherwise the database, which re-issues it on that response |
Admin procedures and the /admin guard, billing, push registration, tool approvals and assistant runs, webhook endpoint and OAuth connection changes (authoritativeSession, getAuthorizedSession) | The database (disableCookieCache) |
Every /api/auth/* request except GET /get-session: password, email, 2FA, passkeys, sessions, Organizations, invitations, impersonation, account deletion, OAuth | The database: createAuth().handler strips the snapshot (withoutSessionSnapshot) |
Membership in the Active Organization (orgProcedure, the files gateway) | The member table on every request, and RLS (app.org_id()) on every statement |
The read after this device changed its session (useAuthMutation({ resetSession }); refreshAuthSession on mobile) | The database, which re-issues the snapshot |
What a change looks like:
| Change | UI shell | Data |
|---|---|---|
| Sign-out on this device | Gone at once: Better Auth expires both cookies | Refused at once |
| Sign-out in another tab of the same browser | That tab leaves for /login at once (session-identity.ts) | Refused at once (same cookies) |
| Session revoked on another device: sign out everywhere, revoke a session, password change or reset, admin revoke, ban | Up to 60 s | Up to 60 s, then 401 and /login |
| A copy of the cookies taken before a sign-out | — | Up to 60 s, like a revocation |
| Member removed from the Active Organization | May show the Organization for up to 60 s | Refused at once: 403 from the membership check, no rows through RLS |
| Platform admin demoted | /admin and admin navigation re-read the role at once | Admin procedures re-read it at once |
The window is the snapshot's maxAge: a shorter one reads the database more often. session.cookieCache.version invalidates every snapshot at once; bump it when the session or user fields the server reads change. A new procedure that creates or reveals a credential builds on userProcedure or adminProcedure (backend conventions).
Organizations and roles
Signup provisions a personal Organization. A User may be a Member of several Organizations; the Session selects one Active Organization. Todos and files belong to that Organization. Platform role admin grants global administration; Org roles owner, admin and member do not grant platform administration.
Membership must be checked at the operation, even if the UI already selected an Organization. An MCP token has no active selection and stays pinned to the User's personal Organization. See connecting agents.
Invitations
An invitation email is the app writing to a stranger on a signed-in User's behalf, so invite-member is treated as outbound mail, not as a form. Four controls apply, all in packages/auth (invitation-policy.ts):
- Role. Only the Organization roles
ownerandadminholdinvitation: create; amemberholds no write permission. These are Better Auth's default roles, passed explicitly in the plugin configuration so a change is a visible diff. - Verified inviter. The inviter's email must be verified, regardless of
AUTH_SKIP_EMAIL_VERIFICATION. With the defaulttrue, an account signs in unverified and can create Organizations, but its first invitation is refused withINVITER_EMAIL_NOT_VERIFIEDuntil the address is confirmed; the web dialog and the mobile invite group show that state and offer to send the verification email. The choice is deliberate: skipping verification is a sign-in convenience for the account holder, not a licence to send mail in their name. - Daily caps. Rolling 24-hour counts on the
invitationtable: 20 per inviter across all Organizations (INVITATION_DAILY_LIMIT_REACHED) and 50 per Organization across all inviters (ORGANIZATION_INVITATION_DAILY_LIMIT_REACHED), both HTTP 429. Better Auth'sinvitationLimitcaps pending invitations per Organization at 50 (INVITATION_LIMIT_REACHED). Constants live inINVITATION_POLICY. - Rate limit.
/organization/invite-memberhas its owncustomRulesentry, 10 requests per 10 minutes per client IP, next to the credential endpoints.
The checks run in beforeCreateInvitation, before the row is written and before any mail is sent, and again in a root hooks.before for the resend: true path, which re-sends an existing invitation without creating one. Counting runs on the handle Better Auth already holds (the auth.better-auth system scope, ADR-0009): one inviter's invitations span Organizations, so no tenant scope could see them all. The email itself never puts inviter text in the subject; see Email.
A refusal is a typed APIError; packages/errors maps each code to a message in every locale, and both clients show it through the shared mutation helper. Re-sending an existing invitation is not counted against the daily caps (the row already exists); the IP rule bounds it.
Web and native
Web auth hooks coordinate TanStack Query, forms and redirects. Mobile uses the Better Auth Expo client, secure credential storage and native passkey integration. Its route gate revalidates the Session and hides private content during a transition. Logout, expiry or an authoritative 401 clears private state. A 403 does not automatically sign out an otherwise valid Session.
Every web form with a password field sets method="post". The server-rendered ones (sign-in, sign-up, reset and change password) also keep submit disabled until useHydrated() is true, so a click before hydration never sends credentials in a GET URL.
Extend safely
Add auth behavior to the server factory and its tests, then expose the outcome through a hook. Keep email callbacks, membership checks and audit recording in their existing owners. Test revoked Sessions and Organization changes as well as successful login. Never treat an impersonated admin's original identity as permission to access /admin.