Skip to content

Sign in with Google and Apple

Patients and therapists can sign in to the web app and to the Otera Health and Okta Health apps with their Google account or their Apple ID, instead of a password. It is switched on per clinic with the socialSignIn flag (Feature Flags), and off everywhere until a clinic asks. Otera Measure has had it since 2026-10-21 (Otera Measure); this page is about the regular app and the web.

The same release closes sign-up on clinic sites: a clinic's patients come through their therapist, so a whitelabel site has no sign-up form (see No sign-up on clinic sites).

What people see

An account that already exists signs straight in. A therapist adds a patient with their email; the patient taps Sign in with Google on their phone or on the clinic's site, and because the Google account carries that same email, verified by Google, they are in. No password to set, no reset link to wait for. The Google or Apple account is linked to the account for next time, and someone who set up a password first can keep using it; the two ways in sit side by side.

On the web (/login): Sign in with Apple and Google's own Sign in with Google button above the email and password form, then an "or" line. On the main sites' sign-up page (/signup) the same two read Continue with…: the account is made from the provider's verified email, with the kind of account chosen above the buttons where therapists may sign themselves up, and no password (the reset link sets one later if wanted). The web signs in to the site it is on: a Google account whose email has an account at another clinic is told there is no account here.

In the app, on the Sign in step (the screen that asks for the email): the Apple button (iPhone) and the Google button above the email field, there as the screen opens (the welcome screen asks ahead). Nobody types an email first. The server looks for every account the person has, on the platform's own tenant and in every clinic that offers Google and Apple sign-in, the way the email step lists them: one signs in, and the clinic's name and logo follow as if the email step had found it; several open the usual Choose your clinic screen, and the choice signs in there. The builder's sign-up at the end of I'm new here offers Continue with Apple and Continue with Google too; the plan just built becomes the account's first week as it does with the form. When the Google or Apple account turns out to have an account already, it signs in: a patient with sessions still ahead (their therapist's, say) is asked first whether the new plan replaces them, as the password sign-in asks, and a therapist goes straight in without the patient plan.

When it does not find an account, the message says so and what to do: If your therapist set you up, sign in with the email they used, or ask them for a sign-in link. An Apple sign-in that carries Apple's relay address gets its own sentence instead: Apple is hiding the email, so it cannot match the account a therapist set up; sign in with the email instead. On the app's sign-in step a New here? Build your plan link follows (not for someone who came from "My therapist invited me"); on the main sites' web sign-in page a Create an account link does. An account at a clinic that does not offer Google and Apple is sent to its password. Staff and clinic administrators always sign in with their password, and are told so; a closed account is told it is closed.

Apple's Hide My Email gives Apple's relay address, not the one the therapist has, so it will not find an account a therapist created. Apple asks only the first time someone uses their Apple ID with the app, so the patient picks Share My Email then. Someone who already chose Hide My Email signs in with their email instead (or a sign-in link); Apple asks again only after they stop using Sign in with Apple for the app in their Apple ID settings. Nothing links an Apple ID to an account from a password sign-in.

No sign-up on clinic sites

A clinic's site is for its patients, who come through their therapist (an invitation email, a sign-in link, the in-clinic QR code) or through the clinic's own /join link where the clinic switched that on (patientSelfSignup). So on every tenant other than the platform's own site:

  • the Sign up links in the header and on the sign-in page are gone,
  • /signup says Sign-up is by invitation with a Sign in button,
  • the API refuses every sign-up (POST /v2/user and a Google or Apple sign-up alike) with 403 SIGNUP_DISABLED,
  • and the web plan builder's older ways in make no account there either: its emailed link answers SIGNUP_DISABLED for an email with no account, and its emailed code is never sent to one (the answer reads as if it had been, so it reveals nothing). Both still sign in an account that exists. Otera Measure gets no patient accounts this way.

The main Otera and OKTA sites, and Otera Measure, keep their sign-up as before. Technically, sign-up is open on the main tenant (id 1 on each server) and wherever doctorSelfSignup is on, the flag that already marked the self-serve tenants; nothing needs setting on clinics.

The apps are not affected: their sign-up always lands on the main tenant.

Switching it on

  1. API environment (once per server). The web needs its own clients: GOOGLE_SIGN_IN_WEB_CLIENT_ID (a Google OAuth client of the Web application kind) and APPLE_SIGN_IN_WEB_CLIENT_ID (an Apple Services ID). The apps' clients stay in GOOGLE_SIGN_IN_CLIENT_IDS (the iOS and Android OAuth clients of each app, comma-separated) and APPLE_SIGN_IN_AUDIENCES (the bundle ids: com.fedyam.oterahealth.us, com.fedyam.oktahealth.ru, and Otera Measure's). The web clients count as accepted audiences on their own. Without a provider's client the web shows no button for it, and the API answers 503 PROVIDER_NOT_CONFIGURED to a token for it.
  2. Google Cloud. In the project with the published OAuth consent screen (set up for Otera Measure), a Web application client whose Authorized JavaScript origins list every site the buttons should appear on, each as its own origin (https://app.oterahealth.com, https://knosispt.oterahealth.com, …; Google takes no wildcards), plus http://localhost:3000 for local work. No redirect URI is needed: the button hands the token to the page. For the apps, an iOS client per bundle id and an Android client per package with the signing certificate's SHA-1, as described for Otera Measure in its page.
  3. Apple. The Sign in with Apple capability on both App IDs (EAS turns it on from usesAppleSignIn when it syncs capabilities), and for the web a Services ID with Sign in with Apple enabled, grouped with the app's App ID, listing each site's domain and https://<site>/login as a return URL. Apple caps how many domains and return URLs one Services ID carries; check the current limit before adding many clinics, and keep the sites that need it first.
  4. The flag. socialSignIn: true on the main Otera or OKTA tenant shows the buttons on its site and in its app (the app reads the main tenant's flag to show them at all); socialSignIn: true on a clinic shows them on the clinic's site and lets the app find the clinic's accounts.
  5. The app. The Google client ids of each app go into the production-us / production-ru (and preview) profiles of eas.json and the matching update:* scripts as EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID and EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID; without one for its platform the Google button does not show (Apple's needs none). The Apple entitlement and the app's id as a URL scheme are native, so this is a new store build, version 2.3.0, not an update: the runtime version follows the app version, and an update with this code never reaches a 2.2.0 binary.

Order: the API first (the old web and app ignore the new fields), then the web, then the app build. Nothing changes for anyone until a tenant's flag is on, except the closed sign-up on clinic sites, which applies as soon as the API is out. A web deploy that lands before the API's keeps the main sites' sign-up (the build decides until the API says).

Internals

Internal detail; the user-facing behaviour is above.

Area Where
Endpoint POST /v2/user/social-login (OktaPT-API/src/routes/user/socialLogin.controllers.ts): provider, idToken, optional tenantId (sign in to that tenant instead of the request's origin, as POST /v2/user/login allows), anyTenant (the app: look in every tenant when the first has no account; one match signs in there, several answer 409 CHOOSE_TENANT with tenants in the shape of /v2/user/check-email), createIfMissing + type (sign-up), firstName/lastName (Apple's one-time name), timezone. The answer is the password sign-in's plus isNewAccount and tenant (id, name, displayName, domain, logoUrl, primaryColor), which the app keeps as the email step would. A patient's open sign-in links are retired on the way in, as after a password
Who gets in Without anyTenant (the web, and the app after a clinic choice): a linked provider account in the tenant, else the account with the token's verified email there, which is linked then (the earlier "only where the provider owns the address" rule is gone: a verified email is the same proof of the mailbox a reset link asks for). With anyTenant (the app's sign-in step): every account by linked identity (an Apple relay address still finds an account linked before) and by verified email, in the request's tenant and in the clinics alike, never Otera Measure's tenant; one per tenant (the linked account first); a clinic without the flag is left out, and PASSWORD_REQUIRED answers when only such clinics have the email; one left signs in, several answer 409 CHOOSE_TENANT (the request's tenant first). Then NO_ACCOUNT, or an account if asked. Closed accounts (ACCOUNT_CLOSED), staff and clinic admins (PASSWORD_REQUIRED) are refused on every path, also when they are the only accounts found. A tenantId that names no tenant answers like one without the flag (403 SOCIAL_SIGN_IN_DISABLED), so ids cannot be probed
Links and email changes A link is made through an email, so it goes with it: when a therapist corrects a patient's email (PATCH /v2/doctor/patients/:id) or the admin changes a user's email (PATCH /v2/admin/users/:userId), that account's UserIdentity rows are deleted; whoever holds the old address is out. The person's own email change keeps them. Account deletion removed them already
Sign-up rules services/selfSignup.ts: selfSignupOpen(features, tenantId) is tenantId === MAIN_TENANT_ID || doctorSelfSignup === true; POST /v2/user and a provider sign-up answer 403 SIGNUP_DISABLED elsewhere, before the per-type rules (DOCTOR_SIGNUP_DISABLED, PATIENT_SIGNUP_DISABLED). isPatientSelfSignupOpen (open, and not Otera Measure's tenant) guards the legacy routes that made accounts on the way in: GET /auth/verify-magic-link/:token, POST /auth/2fa/send-code and POST /auth/2fa/verify-with-workout-plan in routes/legacy.ts. validateUser refuses non-text fields with 400 (a non-text email used to throw outside the handler's try block and stop the API)
Token check services/socialIdentity.ts: configuredAudiences(provider) is the comma-separated list plus the web client id; webClientId(provider) is what the public features publish
Public features GET /v2/tenant/public-features → features.selfSignup, features.socialSignIn (resolved: Measure's tenant, or the flag), and signIn: { google, apple } with the web client ids, null where a provider is not set up or the tenant does not offer it (services/socialSignIn.ts)
Web lib/stores/publicTenant.ts (the features, asked once per page load and again after a failed try; selfSignup from the build's brand until the API answers, so the header and /signup draw right on the first frame) and lib/auth/publicFeatures.ts (the answer read field by field: an API older than this sends no selfSignup, and the build's answer stands in), the header asking only on clinic builds, lib/auth/socialSignIn.ts (Google Identity Services draws its button and hands over the ID token, initialized once per client id; Sign in with Apple JS is loaded and set up when the button is drawn, so signIn() opens Apple's window straight from the click as popup blockers require (usePopup, return URL /login); both scripts load only on pages with the buttons; Apple's one-time name kept per Apple ID for the page load; isApplePrivateRelay for the Hide My Email sentence), components/auth/SocialSignInButtons.tsx, useAuthStore.socialLogin, lib/auth/socialSignInErrors.ts, pages/login.tsx, pages/signup.tsx + components/auth/SignupForm.tsx (account type first, then the provider buttons), components/header/AuthSection.tsx and MobileMenu.tsx (no Sign up without selfSignup), the admin flag catalog, login.social.* and signup.closed.* strings. The proxy's 401 handler leaves /user/social-login alone like /user/login
App app.config.js (the bundle id as a URL scheme, usesAppleSignIn and the expo-apple-authentication plugin for every build; version 2.3.0), AuthContext.socialLogin (tenantId, anyTenant, the error's tenants, the answer's tenant remembered through tenantService), services/tenantFeatureService.fetchPublicFeatures + hooks/useSocialSignInOffered.ts (the buttons only where the build's own tenant offers them, since the provider sign-in posts there; kept five minutes and asked ahead from the welcome screen), lib/auth/pendingSocialSignIn.ts (the credential held in memory between the sign-in step and the clinic choice), lib/auth/socialNotice.ts, app/email-entry.tsx, app/tenant-selection.tsx (social=1; the held credential goes with the list, and an expired one sends the person back), app/ai-workout-builder/sign-up.tsx (an existing account: the outstanding-sessions question before the plan is adopted), lib/auth/identityToken.ts (the token's email, for the Hide My Email sentence), a lock against a second tap, the screens' route params read once (on Android, Google's return link reaches the router too), socialSignIn.* strings
Tests API: user-social-login.test.ts (email linking for any verified address, tenantId incl. a flag-off and an unknown one, the anyTenant search: the request's own tenant beside a clinic, one clinic listed once, staff and closed accounts, mixed flags), tenant-public-features.test.ts, user-auth.test.ts (sign-up closed on a clinic, open on Measure), self-signup-closed.test.ts (the legacy link and code routes per tenant, the non-text email that stopped the API), admin-users-update.test.ts and doctor-patient-email-links.test.ts (links dropped with a corrected email). Web: npm run test:sign-in (the features answer, old API included). App: signInSocialScreens.test.tsx (the sign-in step incl. Hide My Email, double taps and Android's return link, the clinic choice incl. an expired token and leaving the list, the builder's sign-up incl. an existing account with sessions ahead and a therapist), socialSignInOffered.test.tsx, identityToken.test.ts, authSocialLogin.test.tsx, socialSignInErrors.test.ts

Known limits

  • A verified email is the key. Google and Apple vouch that the person proved the address when the provider account was made, not that they hold it today; an address that changed hands (a recycled work address) opens the account that has it now. Staff correcting an email drop the old links (above).
  • Accounts made with an unproven email. The main sites' sign-up (and a clinic's /join where it is on) does not confirm the email. Someone who registered another person's address with a password keeps that password after the real owner signs in with Google or Apple and lands in the same account. Recording which accounts proved their mailbox (invitation onboarding, a reset or sign-in link, a code, a provider) would close this; not built.
  • The emailed code. A provider sign-in stands in for the web's emailed-code step: those code accounts are passwordless, and the provider vouches for the same inbox the code goes to. SMS code accounts have no email and so never match.
  • Apple account deletion. Apple asks apps to revoke a Sign in with Apple token when the account is deleted; that needs the Apple key and is not built (as for Otera Measure).