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.
- no orgs → create (step 4)
- one org → sign in (step 5)
- many orgs → let the user pick, then sign in
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:
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.