Auth and Tenancy
The headline decision: choose the tenant before you write the first product table. The tenant is the thing that owns data, access, and the subscription.
1. Decide the tenant before writing a schema
Ask one question: could two people ever need to share this data?
- If no, make the person the tenant.
- If yes, model an organization from day one.
The UI can hide organizations at first. The data model cannot.
Create one organization for each early user if the product starts with one person.
That keeps later invitations and shared data inside the same ownership model.
For a person tenant, product rows and billing records use userId.
A single-user schema usually puts userId on every product row.
An organization retrofit must add organizationId, create default organizations, and backfill every row.
It must decide ownership for shared records and repair foreign keys, unique indexes, URLs, exports, and APIs.
It must rewrite queries and move Stripe customers and subscriptions to the new owner.
Ambiguous ownership makes the backfill a product decision, not a migration detail.
The work can require a long migration, dual reads, and a support plan.
Do not choose a person tenant because the first screen has one account.
Choose it only when shared ownership is outside the product's future.
2. The data model
Better Auth's Organization plugin supports organizations, members, invitations, and roles.
It adds organization, member, and invitation tables.
Its schema includes optional activeOrganizationId on sessions.
Generate or manually apply the required session fields.
Read the Better Auth Organization docs before generating migrations.
The minimal organization model is:
This shows the tenancy subset; Better Auth also needs its core user, session, account, and verification tables.
user -- Better Auth identity
organization(id, name, slug, created_at)
member(id, user_id, organization_id, role, created_at)
invitation(id, email, inviter_id, organization_id, role, status, expires_at, created_at)
document(id, organization_id, ...)
Add a unique (user_id, organization_id) membership key, a unique organization slug, and tenant foreign keys.
The member row is the forgotten table.
It joins a person to an organization and stores that person's role.
Without it, the product cannot represent one user in two organizations.
Do not put organizationId or role on user.
Every tenant-owned table gets a non-null organizationId.
Every tenant-scoped query filters on that value, without exception.
Include it in reads, updates, deletes, counts, search, exports, and uniqueness checks.
Apply the same boundary to queue jobs, cron work, R2 keys, and signed URLs.
Use SELECT * FROM document WHERE organization_id = ? AND id = ?.
WHERE id = ? alone is a cross-tenant data leak waiting to happen.
Use the organization ID in compound indexes and conflict checks too.
Keep Better Auth's generated schema and your app schema in one migration flow.
The Drizzle adapter docs require matching table names.
If you rename tables, pass the explicit schema mapping.
Generate the schema and Drizzle migration, then inspect its SQL before applying it to D1.
3. Resolve the tenant from the session
The classic multi-tenant bug has this shape:
- The browser sends
organizationId. - The server uses it without checking membership.
- One customer reads or changes another customer's row.
Never trust a tenant ID from the client.
Resolve the signed-in user from the Better Auth session on the server.
Resolve the requested organization from the session's active organization or route context.
An active organization selector is context, not proof of access.
Then query member for both userId and organizationId.
Reject the request when that membership does not exist.
The authorization sequence is: session identity → requested organization → membership → role → resource query.
The resource query still filters on organizationId after the membership check.
The check proves access; it does not scope a later query automatically.
Better Auth exposes auth.api.getSession and auth.api.getActiveMember.
Use them with request headers, not a browser-supplied user object.
See session management and Organization access control.
4. Keep roles small
Start with three roles: owner, admin, and member.
ownercontrols the organization and its ownership.adminmanages members and product settings.memberuses the shared product data.
Better Auth defines these default roles.
Its default admin cannot delete the organization or change the owner.
Its default member cannot create, update, or delete organization resources.
Attach a role check to the server action and the resource operation.
Do not attach it only to a hidden button or disabled form.
Check the role after membership and before the mutation.
Keep role names separate from subscription plans.
An organization's plan may limit features, but it does not prove a user's permission.
Do not create custom roles for hypothetical future needs. Add a permission only when a real product action needs a different boundary. Use Better Auth's access control APIs for custom permissions, and test them on the server.
5. Choose auth methods by support cost
Email OTP and magic links reduce password friction.
They make email delivery, expiry, rate limits, and mailbox access part of account security.
They create support cases for delayed mail, blocked links, and wrong inboxes.
Better Auth's Email OTP plugin uses sendVerificationOTP.
Its documented defaults are six digits, five minutes, and three attempts.
The Magic Link plugin uses sendMagicLink.
Its documented link expiry is five minutes.
Treat the link as a login credential.
Passwords add signup and reset friction, reduce delivery dependence, and create reset, reuse, breach, and support work.
OAuth reduces typing but adds provider setup, callback failures, outages, identity linking, and email trust decisions. Better Auth supports built-in OAuth and OpenID Connect providers. Do not treat a provider email claim as a membership grant. Choose one primary path and one recovery path first. Ask whether each method shortens time to the first useful action. Use user-onboarding for that friction question.
6. Invitations and their edge cases
An invitation is an organization action, not a user-profile field.
Store its target email, organization, inviter, role, status, and expiry.
Make acceptance require an authenticated session whose email matches the invitation.
For an existing user, accept the invitation and create the membership row.
Do not create a second user account.
Better Auth documents auth.api.createInvitation, auth.api.acceptInvitation, and auth.api.cancelInvitation.
The invitation email must carry the invitation ID, and acceptance must run after login.
For a signup with a different email, stop acceptance.
Do not match on display name, browser, or a client-supplied user ID.
Offer a change-email or resend path with a new invitation.
Better Auth checks the session email against the invitation in its normal flow.
Use requireEmailVerificationOnInvitation: true when the invitation ID can leak or email proof must be stronger.
If the inviter leaves, do not leave a live invitation owned by that account.
Cancel it, or transfer responsibility to an active owner or admin.
Notify the recipient when the invitation becomes invalid.
Define a last-owner transfer rule before allowing owner departure.
Better Auth cancels an invitation when the target is already a member.
It does not resend an existing invitation unless resend is true.
Test duplicate, expired, canceled, and concurrent invitations.
7. Put billing at the tenant boundary
If the paying customer is an organization, the subscription belongs to it.
Keep Stripe customer and subscription IDs in a billing record keyed by organizationId.
Attaching billing to a user breaks when that user belongs to two organizations.
It makes a teammate's plan control the wrong workspace and prevents clean transfers, seats, and cancellation.
The browser can identify a plan key. The server must resolve the organization, authorize the action, select the Stripe customer and price, and update entitlements from webhooks.
Read saas-billing-stripe for Checkout, webhook, access, and portal rules. Adapt its local user mapping to the organization when this skill chooses organization tenancy. Keep billing state separate from membership state: membership answers who may enter, and billing answers what the organization may use.
8. Sessions and role changes
A session proves identity, not current organization membership or role.
Query membership during protected server work.
Do not copy a role into a long-lived client token and trust it later.
Better Auth sessions expire after seven days by default and extend after updateAge, which defaults to one day.
Set expiresIn, updateAge, and freshAge for the product's risk.
Use shorter freshness windows for owner actions and billing changes.
See Better Auth session expiration.
Make role removal effective on the next authorization check.
Revoke sessions after membership changes when immediate logout is required.
For administrator removal, use auth.api.revokeUserSessions from the Better Auth Admin plugin.
“Log out everywhere” means server-side revocation for every active session.
Clearing one browser cookie does not end sessions on other devices.
Better Auth provides revokeSessions for this purpose.
If session.cookieCache is enabled, revocation can wait until its cache expires on another device.
Disable it for sensitive actions, or set a short cache age.
Do not promise immediate revocation while allowing a long cached session.
Checklist
- Ask whether two people may ever share the product data.
- Choose the person or organization as the tenant before writing product tables.
- If the tenant is an organization, create
organizationIdon every tenant-owned table. - Model
memberas the user-to-organization authorization row. - Add uniqueness and foreign-key constraints that include the organization boundary.
- Generate Better Auth's Organization schema and inspect the Drizzle migration for D1.
- Resolve identity from the server session, then verify membership for the requested organization.
- Filter every tenant query on
organizationId, including reads, writes, counts, and exports. - Keep
owner,admin, andmemberuntil a real permission boundary requires more. - Choose auth methods by time to value and email or password support cost.
- Require the invited email to match the authenticated session before acceptance.
- Define what happens to pending invitations when an inviter leaves.
- Store the Stripe subscription against the paying tenant, not an incidental user.
- Recheck membership and role after session lookup on every protected server operation.
- Test two organizations sharing one user, cross-tenant IDs, role downgrade, member removal, and billing.
- Set session expiry and freshness deliberately, and define “log out everywhere”.
Related
build-from-template— the scaffold this decision has to be made in, before the first schema lands.saas-billing-stripe— the subscription belongs to the tenant this skill defines, not to a user.user-onboarding— sign-up friction and invitations are the same surface.ship-a-product— where this sits in the build stage.