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)inapp.tshands the request to the Hono app.- The Hono app routes
GETandPOSTon/api/auth/**toauth.handler(c.req.raw). - better-auth uses
prismaAdapter(db, { provider: 'postgresql' }), wheredbis the Prisma singleton from@repo/database. Its tables are in theauthPostgres schema. baseURLandtrustedOriginscome fromNEXT_PUBLIC_SAAS_URLthroughgetBaseUrlandgetTrustedOriginsin@repo/utils.AUTH_TRUSTED_ORIGINSadds more origins, comma separated.advanced.database.generateIdis false, so ids come from the database defaults (cuid()in the schema).
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:
- Loads the user.
- Calls
claimStaffSeats(userId, email). - Sets
activeOrganizationIdto the user’slastActiveOrganizationId, 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 inpackages/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 globalbefore 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 throwsBAD_REQUESTwith codeINVALID_PHONE_NUMBER. - The value is rewritten to an MSISDN with
toMsisdn(digits with country code, no plus) before better-auth sees it.User.phoneNumberis stored in that form and is unique. /sign-up/emailgets the same normalization when the body includesphoneNumber.
Sending the code
sendOTP calls sendLoginOtpOverWhatsapp(phoneNumber, code) in lib/phone-otp.ts:
- With
TRAINEE_OTP_DEV_MODEset totrueit logs the code and sends nothing. - Otherwise it builds a SmartSend client from
SMARTSEND_BASE_URLandSMARTSEND_OTP_API_KEY(falling back toSMARTSEND_API_KEY) and sends theregistercodetemplate in Hebrew with the code as both the body parameter and the URL button parameter. - Missing configuration throws
whatsapp-not-configured.
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:
findUserIdByPhoneNumber: a user whosephoneNumberequals the MSISDN.linkPhoneNumberFromCoachRecord: an activeCoachrow whosephonematches one of the number’s variants. The coach is resolved to a user byexternalUserId, or by the directory email when there is none. If no account exists at all,provisionStaffAccountcreates 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.
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 anafter 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 aStudio 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.
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 throughsendEmail 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).