Coach identity is handled by better-auth, configured in packages/auth/src/auth.ts (@repo/auth). The instance runs inside the API process. Trainees do not use it. They have their own token, described on Trainee sessions.

How it is served

  • app.use('/api', honoGateway) in app.ts hands the request to the Hono app.
  • The Hono app routes GET and POST on /api/auth/** to auth.handler(c.req.raw).
  • better-auth uses prismaAdapter(db, { provider: 'postgresql' }), where db is the Prisma singleton from @repo/database. Its tables are in the auth Postgres schema.
  • baseURL and trustedOrigins come from NEXT_PUBLIC_SAAS_URL through getBaseUrl and getTrustedOrigins in @repo/utils. AUTH_TRUSTED_ORIGINS adds more origins, comma separated.
  • advanced.database.generateId is false, so ids come from the database defaults (cuid() in the schema).
The same auth object is on AppContext as ctx.auth, which lets the bearer lane verify sessions without an HTTP round trip when SESSION_VERIFY_INPROCESS is true.

Sessions

The session create hook

databaseHooks.session.create.before runs every time a session is created:
  1. Loads the user.
  2. Calls claimStaffSeats(userId, email).
  3. Sets activeOrganizationId to the user’s lastActiveOrganizationId, or else the organization returned by the claim.
claimStaffSeats (lib/claim-staff-seats.ts) exists because adding someone under Business settings, Team writes a Coach row and nothing else. The staff member has no organization membership and their coach row has no link to an account. On sign-in the function finds active coach rows that either already point at this user (externalUserId) or have no user and match the email case insensitively, links them, and creates or updates the better-auth member row with the role from organizationRoleForStaff. A first time staff member therefore lands inside their studio instead of an empty dashboard.

Sign-in methods

Feature flags are in packages/auth/src/config.ts. All of these are on: signup, magic link, social login, passkeys, password login, two factor. Other plugins: admin() (platform admin role, impersonation), organization() (studios, members, invitations), openAPI() and the local invitationOnlyPlugin().

Phone OTP for coaches

Number handling

A global before hook normalizes phone numbers on these paths: /sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password.
  • The raw value must pass isValidLoginPhoneNumber, otherwise the hook throws BAD_REQUEST with code INVALID_PHONE_NUMBER.
  • The value is rewritten to an MSISDN with toMsisdn (digits with country code, no plus) before better-auth sees it. User.phoneNumber is stored in that form and is unique.
  • /sign-up/email gets the same normalization when the body includes phoneNumber.

Sending the code

sendOTP calls sendLoginOtpOverWhatsapp(phoneNumber, code) in lib/phone-otp.ts:
  • With TRAINEE_OTP_DEV_MODE set to true it logs the code and sends nothing.
  • Otherwise it builds a SmartSend client from SMARTSEND_BASE_URL and SMARTSEND_OTP_API_KEY (falling back to SMARTSEND_API_KEY) and sends the registercode template in Hebrew with the code as both the body parameter and the URL button parameter.
  • Missing configuration throws whatsapp-not-configured.
This is the same template and the same key the trainee login uses.

Unknown numbers and staff

On /phone-number/send-otp without an existing session, the hook calls resolveUserIdForLoginPhone. If it returns nothing the request fails with BAD_REQUEST, code PHONE_NUMBER_NOT_EXIST. Resolution has two steps:
  1. findUserIdByPhoneNumber: a user whose phoneNumber equals the MSISDN.
  2. linkPhoneNumberFromCoachRecord: an active Coach row whose phone matches one of the number’s variants. The coach is resolved to a user by externalUserId, or by the directory email when there is none. If no account exists at all, provisionStaffAccount creates one from the directory entry. If an account exists without a phone number, the number is written to it. If the account already has a different number, resolution fails.
So a coach who was only ever listed in the team directory can sign in with the phone number their studio entered, with no manual backfill.

Email OTP

emailOTP has sign-up disabled, so by default a code would never be sent to an address without an account. The before hook closes that gap for staff: on /email-otp/send-verification-otp with type equal to sign-in, it calls provisionStaffAccountForEmail(email), which creates an account when the email belongs to a listed staff member. The created account has no password and an unverified email, and the web app’s set-password gate finishes the setup. The code is delivered with the emailOtp mail template in the locale from the NEXT_LOCALE cookie.

Two factor on OTP sign-ins

better-auth raises its second factor challenge from an after hook that only matches its own password sign-in routes. A user with 2FA enabled could skip it by signing in with a phone or email code. plugins/two-factor-otp-lanes.ts wraps the stock plugin and widens each after hook’s matcher to also match /phone-number/verify and /sign-in/email-otp. The plugin’s own challenge handler is reused, not reimplemented.

Organizations, invitations and coach rows

A studio is a better-auth organization. The domain side keeps a Studio row linked by externalOrgId and a Coach row per member linked by externalUserId. The after hook keeps them in step: provisionCoach (lib/provision-coach.ts) makes a signed POST /v1/internal/coaches call to CORE_API_URL. It skips with a warning when CORE_API_URL or SERVICE_AUTH_SECRET is not set, and logs but does not throw on failure.
provisionCoach sends studioId: invitation.organizationId, which is the better-auth organization id. The handler behind /v1/internal/coaches upserts a Coach with that value as Coach.studioId, which is a foreign key to Studio.id. Those are different ids unless they happen to match, so this call looks like it cannot succeed for a normal studio. Its failures are only logged. In practice coach rows are created by the team screen (POST /v1/web/coaches) and linked by claimStaffSeats. Confirm the behaviour before relying on the invitation path.
updateSeatsInOrganizationSubscription only acts on the legacy seat add-on subscription. For every other studio it is a no-op. Billing never follows the team size on its own. See Billing and plan limits. markOnboardingFinished stamps settings.onboarding.completedAt on studios the /start wizard bootstrapped. Plan limits read that stamp, never the user’s onboardingComplete flag.

Invitation only sign-up

invitationOnlyPlugin blocks /sign-up/email without a pending invitation, but only when config.enableSignup is false. Sign-up is enabled today, so the plugin passes every request through. config.organizations.forbiddenOrganizationSlugs lists slugs that collide with web app routes, including sign, the public signing page.

Deletion hooks

Deleting an account or an organization has side effects that must run first (lib/deletion-hooks.ts): A subscription that is already revoked or missing is logged and does not block the deletion. archiveStudioForOrganization (lib/studio-lifecycle.ts) writes to the domain tables directly through db. It used to be a signed HTTP call the API made to itself, and every way that call could fail ended as a log line and a studio whose trainees could still sign in. Archiving sets Studio.deletedAt, releases the organization id, parks the slug under a suffix, archives clients and stops push tokens, API keys, hooks, automations and flow runs.

Emails sent by auth

All go through sendEmail from @repo/mail with a template id and the request’s locale: A new user also gets an in-app welcome notification from createWelcomeNotification in the user.create.after hook. A failure there is logged and does not fail the sign-up.

How the rest of the API sees a coach

Exports of @repo/auth

Errors from the auth API are logged through onAPIError with logError('Auth API error', error).