# Authentication and Organizations

Better Auth owns identity, sessions, Organizations, platform roles and OAuth.

## Session outcomes [#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 authorization always reads the database; Better Auth's signed cookie snapshot only answers the browser's presentation read (see [Session reads](#session-reads)).

## Session reads [#session-reads]

The session row in the database is the authority. 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). That snapshot only shapes the UI:

| Read                                                                                                       | Source                                                                                                                                                       |
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The browser's presentation read: the `/_auth` guard, `useSession`, tab focus (`GET /api/auth/get-session`) | The snapshot while it is younger than 60 s, without a statement; otherwise the database, which re-issues it                                                  |
| SSR guard and prefetches, the oRPC context, the files gateway, the admin guard and admin procedures        | The database: `getAuthorizedSession` passes `disableCookieCache`                                                                                             |
| Every other `/api/auth/*` request (organization endpoints, invitations, update-user)                       | The database: `createAuth().handler` strips the snapshot from the request (`withoutSessionSnapshot`), so Better Auth's own endpoints never authorize with it |
| The read after this device changed its session (`useAuthMutation({ resetSession })`)                       | The database (`disableCookieCache`), which re-issues the snapshot so later presentation reads agree                                                          |

Every database read re-issues the snapshot, so server-rendered pages and oRPC responses also refresh it. The `/_auth` guard never waits on a cached signed-in session: it lets the navigation start and re-reads in the background once the read is older than 15 s (`SESSION_STALE_MS`). `GET /get-session` runs outside the request transaction.

What a revocation looks like:

| Change                                                                     | UI shell                                                                    | Data                                                            |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Sign-out on this device                                                    | Gone at once: Better Auth expires both cookies                              | Refused                                                         |
| Sign-out in another tab                                                    | That tab leaves for `/login` at once (`session-identity.ts`)                | Refused                                                         |
| Session revoked on another device (sign out everywhere, admin revoke, ban) | May stay for up to 60 s, until the snapshot expires or the page is reloaded | The first data request answers 401 and the app goes to `/login` |
| Member removed from the Active Organization                                | May keep showing the Organization                                           | Refused at once: the membership check and RLS answer 403        |
| Platform admin demoted                                                     | Admin navigation and `/admin` re-read the role from the database            | Admin procedures re-read it too                                 |

The native app keeps the same cookies through the Expo client, so its `useSession` reads the snapshot under the same 60 s window. Its oRPC calls stay authoritative, a 401 clears the local session, and `refreshAuthSession` bypasses the snapshot after its own changes. To invalidate every snapshot at once (for example after changing what the session carries), set `session.cookieCache.version`.

## Organizations and roles [#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](/docs/connect-your-agent).

## Invitations [#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`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/invitation-policy.ts)):

- **Role.** Only the Organization roles `owner` and `admin` hold `invitation: create`; a `member` holds 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 default `true`, an account signs in unverified and can create Organizations, but its first invitation is refused with `INVITER_EMAIL_NOT_VERIFIED` until 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 `invitation` table: 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's `invitationLimit` caps pending invitations per Organization at 50 (`INVITATION_LIMIT_REACHED`). Constants live in `INVITATION_POLICY`.
- **Rate limit.** `/organization/invite-member` has its own `customRules` entry, 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](/docs/features/email#user-controlled-text).

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.

## Rate limits [#rate-limits]

Better Auth rate limiting is on everywhere except Vitest. Its Postgres storage uses a separate connection and one upsert per counted request, so a rolled-back failure still counts. The hourly auth cleanup sweeps expired rows. The key is the trusted client IP (`cf-connecting-ip` on Workers, resolved by `client-ip.ts`) and the auth path.

| Path | Limit | Reason |
| --- | --- | --- |
| `/get-session` | Exempt | The read can answer from the signed snapshot without a database statement. |
| `/jwks` | Exempt | Public keys; MCP loads them at most once a minute per isolate. |
| `/sign-in/email`, `/sign-up/email` | 5 per 60 seconds | Limit credential guessing and account creation. |
| `/request-password-reset`, `/reset-password` | 3 per 60 seconds | Limit password reset attempts. |
| `/organization/invite-member` | 10 per 600 seconds | Limit outbound invitations. |

Every other path uses Better Auth's defaults: 100 per 10 seconds, with 3 per 10 seconds for built-in sign-in, sign-up, password change and email change paths unless overridden above. A 429 response carries `Retry-After`; the handler copies Better Auth 1.7.16’s `X-Retry-After` value to the standard header. Rate-limit tests opt in with `NODE_ENV=production`.

## Web and native [#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 [#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`.

## Key files [#key-files]

- [`packages/auth/src/index.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/index.ts)
- [`packages/auth/src/invitation-policy.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/invitation-policy.ts)
- [`packages/auth/src/session-outcome.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/auth/src/session-outcome.ts)
- [`packages/db/src/provision-organization.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/packages/db/src/provision-organization.ts)
- [`apps/web/src/features/auth/auth.sign-in.hooks.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/web/src/features/auth/auth.sign-in.hooks.ts)
- [`apps/mobile/src/platform/auth/use-session-lifecycle.ts`](https://github.com/CarlosZiegler/template-cloudflare/blob/main/apps/mobile/src/platform/auth/use-session-lifecycle.ts)

