//Multi-Org Onboarding (Email → Verify → Create or Sign In)

Multi-Org Onboarding: Email → Verify → Create or Sign In

For a multi-tenant app where one person can belong to many organizations, authenticate the person first (verify their email, org-less), then branch: an existing user signs into one of their orgs, a new user creates one.

The model

ButtrBase separates the account* (the person, keyed by email) from the **org** (a tenant workspace) under an *app. Email verification is account-level and needs no org. The org decision comes afterward.

Fast login: two token tiers

Login feels instant because it uses two tiers:

  • HS256* — issued immediately on verify/login. Fast, but *not verifiable by a public-JWKS backend.
  • RS256* — issued on *refresh. This is the JWKS-verifiable session token your backend validates.
  • The client stores the HS256 token instantly (user is "in"), then a background service-job calls refresh to swap in the RS256 token before any JWKS-verified call. This requires the app's auth.token_rs256 feature flag to be enabled (leave auth.token_rs256_from_login off so login stays HS256-fast and RS256 arrives on refresh).

    The flow

    1. Email entry → POST /api/auth/otp emails a PIN. 2. PIN entry → POST /api/auth/otp/verify verifies the account and returns a signup_token (proof of email ownership) plus a refresh token. 3. Decide with POST /api/auth/organizations/lookup:

  • no orgs → create (step 4)
  • one org → sign in (step 5)
  • many orgs → let the user pick, then sign in
4. Create a workspace (new user): validate the name with GET /api/auth/check-org-name, then POST /api/auth/finalize-registration with the signup_token and an org choice of { type: 'create', name }. Finalize consumes the signup_token and atomically creates the user + org bound to the verified account. 5. Sign into an existing org: POST /api/app/auth/otp/send then POST /api/app/auth/otp/verify with the org's org_uuid and org_name — returns the org session token. 6. Fast login done on the HS256 org token. The service-job then calls POST /api/app/auth/refresh to obtain the RS256 token and replaces the stored one.

Client code

The reference helpers live in src/lib/auth.ts:

// [1]+[2] verify email (account-level, org-less)
await sendSignupOtp(email);
const signupToken = await verifySignupOtp(email, pin);

// [3] branch: create vs sign in const orgs = await lookupOrganizations(email);

if (orgs.length === 0) { // [4] new user creates a workspace await checkOrgName(name); const session = await finalizeRegistration({ email, password: "", firstName: "", lastName: "", signupToken, orgName: name, }); onSession(session); } else { // [5] existing user signs into a chosen org const org = orgs[0]; // or the one the user picked await sendOrgOtp(email, org.org_uuid, org.name); const session = await verifyOrgOtp(email, orgPin, org.org_uuid, org.name); onSession(session); }

The RS256 service-job

Runs after login, non-blocking. Swaps the fast HS256 token for the JWKS-verifiable RS256 one:

async function upgradeToRs256(refreshToken) {
  const res = await fetch(apiBase() + "/api/app/auth/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh: refreshToken }),
  });
  const { token } = await res.json();   // RS256 access token
  storeToken(token, "rs256");
}

Gate any call that hits a JWKS-verifying backend on the RS256 tier; purely client-side work can proceed on the HS256 token immediately.

Backend verification

Your backend verifies the RS256 org-scoped session token via JWKS. The Rust SDK (buttrbase-sdk-rust) accepts RS256 only, so it rejects the HS256 fast-login and signup tokens by design — the backend can never be tricked into trusting an email-proof token as a session.