Coach and staff identity is handled by better-auth. It runs inside the core API process and is reached under /api/auth/*. It owns users, sessions, organizations, invitations, passkeys and two-factor. The trainee app does not use it at all. Trainees sign in with their own token, described on Trainee sign-in. This page covers three things: how /api is mounted, the better-auth endpoints the web app relies on with the rules this project adds to them, and the small auth module in the core API that verifies sessions.

How /api is mounted

src/app.ts mounts honoGateway (src/middleware/hono-gateway.ts) at /api. The gateway rebuilds a web Request from the Express request and hands it to the Hono app exported by @repo/api (backend/packages/api/src/index.ts), then copies the response back. The Hono app has base path /api and these routes: Gateway details that affect clients:
  • Bodies. The gateway forwards req.rawBody. Only the JSON parser and the application/octet-stream parser capture raw bytes in app.ts, so a request with another content type reaches better-auth without a body. Send JSON.
  • Cookies. Set-Cookie headers are copied one by one with getSetCookie(). An impersonation response sets three cookies, and joining them into one header made browsers drop all of them.
  • CORS. The Hono app allows the origins from getTrustedOrigins(NEXT_PUBLIC_SAAS_URL) with credentials, methods GET, POST and OPTIONS, and headers Content-Type and Authorization.
  • Limits. /api is not behind either Express rate limiter. better-auth’s own protections apply.
  • Envelope. Responses are better-auth’s and oRPC’s own shapes, never the { ok, data } envelope.
The web app calls these endpoints through the better-auth client (authClient). Requests and responses follow the better-auth version installed in packages/auth. For exact request and response schemas use the generated reference at /api/docs on a running API, not this page.

Sessions

Configured in backend/packages/auth/src/auth.ts and config.ts:
  • Sessions are stored in the database through the Prisma adapter and carried in a cookie. expiresIn is 30 days (sessionCookieMaxAge = 60 * 60 * 24 * 30). freshAge is 0, so no action requires a recently created session.
  • baseURL and trusted origins come from NEXT_PUBLIC_SAAS_URL.
  • Users have three additional fields: onboardingComplete, locale and lastActiveOrganizationId.
  • When a session is created, a database hook sets activeOrganizationId to the user’s lastActiveOrganizationId, or to the organization returned by claimStaffSeats. That function links staff who were added in the team directory by email to their account and membership, so a first-time staff member lands inside their studio.
  • Account linking is enabled for the trusted providers google and github.
The session cookie stays between the browser and the web app. The web app’s server reads the session, then calls /v1/web/* with a service signature and the user’s ids as headers. See Authentication.

Plugins

Email and password sign-in is enabled with a minimum password length of 8, automatic sign-in after sign-up, and no required email verification. Social providers are Google and GitHub, configured from GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.

Endpoints the web app relies on

The list below is taken from the authClient calls in frontend/apps/saas. Paths follow better-auth’s mapping from client method to route. The ones marked “hooked” are referenced by path in this repo’s own hooks and are confirmed from code. The rest are better-auth defaults: confirm them against /api/docs before depending on a detail.

Sign-in and sign-up

Session and account

Organizations

Platform admin

Phone sign-in

Coaches can sign in with a WhatsApp code. The rules are in the hooks.before middleware and lib/phone-otp.ts. The five phone paths (/sign-in/phone-number, /phone-number/send-otp, /phone-number/verify, /phone-number/request-password-reset, /phone-number/reset-password) share one guard:
  1. phoneNumber must be present and pass isValidLoginPhoneNumber. Otherwise the request fails with 400 and code INVALID_PHONE_NUMBER.
  2. The number is normalized to international digits before better-auth sees it, so every lane stores and compares one form.
  3. On /phone-number/send-otp without a session, the number must resolve to a user (resolveUserIdForLoginPhone). Otherwise 400 with code PHONE_NUMBER_NOT_EXIST. With a session the check is skipped, because that is the “change my number” flow.
resolveUserIdForLoginPhone does more than look up the user table. Coaches historically had their phone only on the studio’s Coach row. The function matches the coach row by phone, links it to an account by externalUserId or by the directory email, and provisions an account from the directory entry for a listed staff member who has never signed in. Code settings, shared with the email lane: The code is sent with the WhatsApp template registercode in Hebrew (sendLoginOtpOverWhatsapp).

Email code sign-in

emailOTP runs with disableSignUp: true, so better-auth silently skips the send for an unknown email. That left staff who were only listed in the team directory with a code that never arrived. The before hook fixes it: on /email-otp/send-verification-otp with type sign-in, provisionStaffAccountForEmail creates an account from the directory entry when the email belongs to a listed staff member. The account has no password and an unverified email, and the seat is claimed when the session is created.

Errors

better-auth errors are JSON with a code and a message, with the HTTP status of the error. The codes this project adds: Every better-auth API error is logged through onAPIError.

The auth module in the core API

src/modules/auth/ has no routes. It holds session-verifier.ts, which turns a better-auth session token into the principal used by the bearer lane. Both map the result with principalFromSession:
  • userId is user.id.
  • studioId is the session’s activeOrganizationId.
  • role comes from the member role: owner to OWNER, admin to HEAD_COACH, member or anything else to SUB_COACH.
  • A session with no user or no active organization is rejected.
authenticate in src/middleware/auth.ts uses the verifier and answers 401 missing bearer token or invalid session. It guards only the three bearer mounts listed on the API overview.
packages/auth/src/auth.ts does not register better-auth’s bearer plugin, and the session lookup above depends on better-auth accepting a session token in the Authorization header. Whether get-session resolves such a token in this setup is not confirmed from code. Test it before building a client on the bearer lane.

Environment