better-auth runs inside the core API. The web app holds only the client: packages/auth/client.ts. Every auth call from the browser goes to /api/auth/* on the web origin and is proxied by the catch-all route, so the session cookie belongs to the web origin.

The auth client

packages/auth/client.ts creates authClient with these plugins: The file also exports the types Session, Organization, ActiveOrganization, OrganizationMemberRole and OrganizationInvitationStatus, all inferred from the client.

Feature flags

packages/auth/config.ts: modules/auth/lib/login-modes.ts derives availableLoginModes in the fixed order phone OTP, email OTP, password, magic link, and defaultLoginMode.

Login

/login renders modules/auth/components/LoginForm.tsx. One react-hook-form instance holds a mode field, and the Zod schema is a union keyed on it.
  1. The coach enters a phone number in PhoneField.
  2. authClient.phoneNumber.sendOtp({ phoneNumber }).
  3. The form switches to LoginOtpStep, a six digit InputOTP that submits by itself when the sixth digit is typed.
  4. authClient.phoneNumber.verify({ phoneNumber, code }).
When the API answers PHONE_NUMBER_NOT_EXIST and email OTP is available, the form switches to the email tab and shows the error. A coach who never added a login phone is not left stuck on the default tab.
After any successful sign in, completeSignin invalidates sessionQueryKey and replaces the route with:
  • /organization-invitation/{invitationId} when ?invitationId= is present,
  • else ?redirectTo= when present,
  • else config.redirectAfterSignIn.
A user who is already signed in and opens /login is redirected the same way. LoginOtpStep offers resend and back. Error codes are mapped to translated strings by useAuthErrorMessages() in modules/auth/hooks/errors-messages.ts, with auth.errors.unknown as the fallback. The map includes the OTP codes OTP_EXPIRED, OTP_NOT_FOUND, INVALID_OTP and TOO_MANY_ATTEMPTS.

Signup

There are three ways an account is created.

/start, the self serve wizard

The main path for a new studio. SignupPopup.tsx runs this sequence:
  1. Verify the email. Either Google (authClient.signIn.social with callbackURL and errorCallbackURL pointing back at the wizard with ?signup=1), or a code through /api/onboarding/request-email-otp and /api/onboarding/verify-email-otp.
  2. Verify the phone through /api/onboarding/request-otp and /api/onboarding/verify-otp. These return tokens that are passed to the bootstrap call.
  3. Create the account. A live session (from Google, or from a retry) is reused. Otherwise authClient.signUp.email is called with a random password the coach never sees.
  4. Create the studio: orpcClient.organizations.generateSlug, authClient.organization.create, orpcClient.organizations.assignCode.
  5. bootstrapStudio(slug, payload) in (start)/start/actions.ts posts to /onboarding/bootstrap on the web lane with branding, the verification tokens and the wizard answers.
  6. authClient.updateUser({ onboardingComplete: true }).
Wizard state is saved to localStorage, so the Google redirect loses nothing. Both lanes end with a credential account that has a hidden password. That is what keeps these users from being sent to /set-password by the main layout.

/signup

CoachSignupForm is the older single form version: authClient.signUp.email, then slug, organization and code as above. With ?invitationId= the page renders SignupForm instead, prefilled with the invited email. After signup it calls authClient.organization.acceptInvitation.

Invitation

See Organizations and roles.

Set password

(main)/layout.tsx calls userHasPassword(), which reads /list-accounts and looks for an account with providerId === "credential". A user who signed in with Google or an OTP and has no credential account is redirected to /set-password. SetInitialPasswordForm validates with passwordSchema from @repo/utils and calls orpcClient.users.setPassword({ password }). Then it replaces the route with / and refreshes. passwordSchema requires 8 to 255 characters, no leading or trailing space, and at least one uppercase letter, one lowercase letter, one digit and one special character. PasswordInput shows the criteria live when showPasswordCriteria is set.

Password reset

ForgotPasswordForm calls authClient.requestPasswordReset with redirectTo set to the absolute /reset-password URL. ResetPasswordForm calls authClient.resetPassword and then goes to config.redirectAfterSignIn.

Onboarding

Users created outside /start have onboardingComplete unset. The main layout sends them to /onboarding, where OnboardingForm shows OnboardingAccountStep (name and avatar), calls authClient.updateUser({ onboardingComplete: true }) and goes to ?redirectTo= or /.

Session on the server

Use the helpers in modules/auth/lib/server.ts. They are server only and cached per request.
getSession() passes disableCookieCache=true, so it always asks the API and never trusts a cached cookie payload. It returns null on any failure. For domain calls you rarely need the session directly. resolveStudioContext(slug) loads it together with the organization.

Session on the client

useSession() reads SessionContext from SessionProvider. It throws when used outside the provider. The provider is mounted by the (authenticated), (unauthenticated) and (start) layouts. Call reloadSession() after changing something on the user that other components read, for example the name or avatar.

Sign out and impersonation

UserMenu.tsx calls authClient.signOut() and then navigates to config.redirectAfterLogout (/login). Super admins can impersonate a user from the admin studio screens (ImpersonateButton). While impersonating, the user menu offers to stop, which calls authClient.admin.stopImpersonating().

Proxy and sessions

proxy.ts does not protect routes. It only looks for a cookie whose name contains session_token to decide whether the bare domain shows the marketing page. Access control is done by the layouts, which redirect to /login, and by the core API on every call.

Account settings

modules/settings/components holds the account screens: