Simple Automation Solutions

SaaS Architecture Guide

Building a Multi-Tenant SaaS on Bubble.io

The complete technical blueprint: workspace isolation, role-based access control, Stripe subscription billing, team invitations, usage limits, onboarding flows, and production-grade security — all built correctly in Bubble from day one.

9Core Chapters
60+Code Patterns
~35minRead Time
⏱ 35 min read  ·  Bubble.io SaaS Architecture
app.yoursaas.com/acme-corp/dashboard
AC
Acme Corp
Pro Plan · 8 / 10 seats
2,841Records
8Members
ProPlan
Team Members
A
Alice (Owner)
OWNER
B
Bob Chen
ADMIN
C
Carol Singh
MEMBER
SEAT USAGE
BILLING
Active · $99/mo

What is Multi-Tenancy & Why It’s the Hardest Thing to Get Right

Multi-tenancy is the architecture that powers every B2B SaaS you’ve ever used — Slack, Notion, Linear, HubSpot, Jira. It means a single application serves multiple independent customers (“tenants”), each with their own data, their own team members, their own settings, and their own billing — all in complete isolation from every other tenant. Tenant A can never see, access, or interfere with Tenant B’s data.

This sounds simple. It is not. Multi-tenancy is the single most consequential architectural decision in any SaaS product, and it’s the pattern most commonly botched by first-time Bubble SaaS builders. Getting it wrong early means refactoring every data type, every workflow, and every privacy rule later — typically after you already have paying customers whose data has leaked across tenant boundaries. This guide exists so you never have to do that.

The non-negotiable requirement: Every piece of data in your application — records, files, settings, activity logs, API keys — must belong to exactly one workspace (tenant). Every database query must be scoped to the current workspace. Every privacy rule must verify workspace membership before granting any access. Miss this on even one data type and you have a data leak between tenants — a critical security and compliance failure that will cost you enterprise customers.

The Six Pillars of a Multi-Tenant SaaS on Bubble

🏠

Workspace Layer

A Workspace data type that is the container for all tenant data. Every other data type has a relationship field to Workspace. No data exists without a workspace owner.

👥

Membership & Roles

A Membership junction type connecting Users to Workspaces with a role (Owner, Admin, Member, Viewer). One user can belong to multiple workspaces with different roles in each.

🔒

Privacy Rules (Isolation)

Bubble privacy rules that ensure every data type is only visible to members of the workspace that owns it. This is the enforcement layer — without it, tenancy is theatrical, not real.

💰

Subscription Billing

Billing tied to the workspace, not the individual user. The workspace owner pays. Subscription status, plan limits, and seat counts all live on the Workspace record via Stripe.

📧

Invitation System

A secure mechanism for workspace owners and admins to invite new users by email, with token-based acceptance flows that handle both new and existing Bubble users.

📊

Usage Limit Enforcement

Every plan has limits: seats, records, API calls, storage. The app must check limits before creation actions and display current usage to workspace admins in real time.

Multi-Tenancy Architecture: The Full Stack

Multiple tenants — one Bubble application — isolated data per workspace
🏠
Acme Corp
🏠
Globex Inc
🏠
Initech LLC
🏠
N tenants…
↓ isolated by Workspace ID + Privacy Rules
⚙  Single Bubble Application
Shared codebase  ·  Shared infrastructure  ·  Isolated data per workspace
💰 Stripe
📧 Email (SendGrid)
💬 Webhooks
📊 Analytics
📄  Bubble Native Database — scoped to workspace on every query, enforced by privacy rules

The Core Multi-Tenant Data Model

Before you touch the Design tab, nail this data model. Every other decision in your SaaS build depends on it. These six data types form the non-negotiable foundation of every multi-tenant Bubble SaaS. Build them exactly as described — their relationships to each other cannot be easily redesigned once you have production data.

🏠Workspace
slugtext (unique)
nametext
ownerUser
planPlan
subscription_statusoption set
stripe_customer_idtext
stripe_sub_idtext
trial_ends_atdate
seats_usednumber
logoimage
created_datedate
📋Membership
userUser
workspaceWorkspace
roleoption set
statusoption set
invited_byUser
invited_atdate
joined_atdate
last_activedate
🎫Plan
nametext
stripe_price_motext
stripe_price_yrtext
price_monthlynumber
price_yearlynumber
seat_limitnumber
record_limitnumber
storage_limit_mbnumber
featureslist of text
trial_daysnumber
👤User (extended)
display_nametext
avatarimage
current_workspaceWorkspace
onboarding_doneyes/no
notification_prefstext
last_seen_atdate
📧Invitation
tokentext (unique)
workspaceWorkspace
emailtext
roleoption set
invited_byUser
expires_atdate
accepted_atdate
statusoption set
📝Audit Log
workspaceWorkspace
actorUser
actiontext
resource_typetext
resource_idtext
metadatatext (JSON)
created_datedate

The Golden Rule: Every Data Type Gets a Workspace Field

Every data type you create for your product — Projects, Tasks, Documents, Invoices, Contacts, whatever your app manages — must have a workspace field of type Workspace. This foreign key is what makes your privacy rules enforceable. Without it, there is no way to scope any search to a single tenant’s data.

✓  Correct: workspace field on every type
Project: workspace → Workspace ← MANDATORY name → text created_by → User Task: workspace → Workspace ← also on child types project → Project title → text assignee → User // Creating a record Create Project: workspace = Current User’s current_workspace name = Input’s value // Searching records Search for Projects: workspace = Current User’s current_workspace
✗  Wrong: no workspace field, no scoping
Project: name → text ← no workspace! created_by → User Task: project → Project ← no workspace! title → text // Creating a record Create Project: name = Input’s value // ↑ which tenant owns this? // nobody knows. data is shared. // Searching records Search for Projects: // no constraint = ALL tenants’ data // returned to every user. DATA LEAK.
💡

Store current_workspace on the User Record

Add a current_workspace field of type Workspace to the User data type. This is the user’s currently active workspace. Every search in your app uses Current User’s current_workspace as its workspace constraint. When a user switches workspace, update this field and reload the page. Never derive workspace context from URL parameters alone — it creates subtle inconsistencies and can be manipulated.

Privacy Rules: The Actual Enforcement Layer

The data model defines the structure of your tenancy. Privacy rules are what actually enforce it. Without properly configured privacy rules, your workspace fields are decorative — any user can still search any data type and retrieve records from every tenant. Privacy rules are Bubble’s server-side access control layer, and they must be configured correctly on every single data type in your app.

The fundamental principle: a user should only be able to see a record if they are an active member of the workspace that owns that record. Every privacy rule you write is a variation of this principle.

The Core Privacy Rule Pattern

// Privacy rule on EVERY app data type (Project, Task, Document, etc.) Data Type: Project Rule name: “Workspace members only” Condition: Do a search for Memberships [ user = Current User, workspace = This Project’s workspace, status = Active ]:count > 0 Fields visible: All fields Find in searches: ✓ (when condition is true) View details: ✓ Create: ✓ (checked — creation is controlled by workflows, not privacy rules) Edit: ✓ Delete: ✓ // Apply this SAME pattern to: Task, Document, Comment, // Invoice, Contact — every type your app manages

Privacy Rule Reference: All Six Core Data Types

Data Type Privacy Condition View Edit Delete Search
Workspace Membership exists: user = Current User, workspace = This Workspace, status = Active Owner & Admin only (2nd rule) Owner only (2nd rule)
Membership This Membership’s workspace has any active Membership with user = Current User Self or Admin/Owner Admin/Owner only
Invitation This Invitation’s workspace has active Membership for Current User — OR — token matches URL param (unauthed accept) Admin/Owner only
User Current User (self) — OR — shares any active workspace Membership with Current User Limited fields Self only
Audit Log Membership exists: user = Current User, workspace = This Log’s workspace, role = Owner or Admin
Plan Everyone (public data — plan features should be publicly readable)
App data types
(Project, Task, Doc…)
Active Membership exists: user = Current User, workspace = This Record’s workspace Member+ (role check) Admin/Owner (role check)

Adding Role-Based Edit & Delete Controls

Bubble privacy rules support multiple rules per data type. Add a second rule on each type to control edit and delete permissions based on role. The first rule handles view access for all workspace members; the second rule adds edit/delete gating based on membership role.

// Two-rule pattern on every app data type // Rule 1: View access for all workspace members Condition: Membership[user=Current User, workspace=This’s workspace, status=Active]:count > 0 Permissions: View details ✓, Find in searches ✓, Create ✓ // Rule 2: Edit/delete for admins and owners Condition: Membership[ user = Current User, workspace = This’s workspace, role is in [Admin, Owner], status = Active ]:count > 0 Permissions: Edit ✓, Delete ✓ // For “own record” edit rights (Members can edit their own) Condition: This Project’s created_by = Current User AND active membership exists Permissions: Edit ✓

Never Use “Everyone” on App Data Types

Bubble has a special privacy rule option called “Everyone” that makes records accessible without any condition. Never use this on any data type that belongs to a workspace. It is only appropriate for truly public data like Plan details or public marketing content. A common beginner mistake is leaving the default “Everyone” rule on newly created data types while building, then forgetting to restrict it before launch. Do a full privacy rule audit before going live.

Role-Based Access Control (RBAC)

Most B2B SaaS products have 3–4 roles within a workspace. The most common pattern is Owner, Admin, Member, and Viewer. Roles are stored on the Membership record, not on the User record — because the same user can be an Admin in one workspace and a Member in another. Never store roles on the User.

The Four-Role System (Option Set Definition)

// Create this as an Option Set in Bubble: “Workspace Role” Option Set: Workspace_Role OWNER — created with workspace, one per workspace, non-transferable (or transferable via explicit workflow) ADMIN — invited by Owner/Admin, can manage members and most settings MEMBER — standard seat, full create/edit access to data, cannot manage team VIEWER — read-only access, cannot create or modify any workspace data // Helper: get current user’s role in current workspace // Use this expression throughout your app for role checks Current_Role = Do a search for Memberships [ user = Current User, workspace = Current User’s current_workspace, status = Active ]:first item’s role // Use in conditionals on buttons, sections, and workflows Show “Invite Member” button: when Current_Role is in [Admin, Owner] Show “Delete Workspace” button: when Current_Role = Owner

Full Permission Matrix: What Each Role Can Do

Action Owner Admin Member Viewer
▶ WORKSPACE MANAGEMENT
Edit workspace name / logo
View billing & subscription
Upgrade / downgrade plan
Delete workspace
Transfer ownership
▶ TEAM MANAGEMENT
Invite new members
Remove members
Change member rolesAdmin & below
View team list
▶ DATA ACTIONS
View all workspace records
Create new records
Edit own records
Edit others’ records
Delete recordsOwn only
View audit log
▶ LEAVING & ACCOUNT
Leave workspaceTransfer first
Switch active workspace

Enforcing Roles in Workflows (Not Just in the UI)

Hiding a button based on role is UI-level protection only. An advanced user can trigger workflows directly via API calls if backend protection is missing. Every sensitive action must have a role check inside the workflow itself, not just in the UI conditional that shows the trigger button.

// Example: Backend Workflow — “Delete Project” (API Workflow) // Even though the button is hidden for non-admins in the UI, // the API workflow enforces the check server-side Step 1: Only when condition is true: Membership[user=Current User, workspace=Project’s workspace, role in [Admin,Owner]]:count > 0 // If this condition fails, the workflow terminates here silently Step 2: Make changes to Project — mark as deleted (soft delete) is_deleted = yes deleted_at = Current date/time deleted_by = Current User Step 3: Create Audit Log workspace = Project’s workspace actor = Current User action = “project.deleted” resource_type = “Project” resource_id = Project’s unique id

The Team Invitation System

Inviting team members to a workspace is one of the most technically nuanced flows in any multi-tenant SaaS. It must handle two distinct cases: inviting someone who already has an account in your app, and inviting someone who doesn’t yet exist as a user. The invitation must be secure (token-based), expire after a set period, and correctly create a Membership record regardless of which path the invited person takes.

The Complete Invitation Flow

1
Admin submits email and selects role

Before sending: check seat limit. If Workspace’s seats_used ≥ Workspace’s plan’s seat_limit, block and show upgrade prompt. Otherwise proceed.

// Workflow: “Invite Member” Condition check: Workspace’s seats_used < Workspace’s plan’s seat_limit Step 1: Create Invitation token = Generate random string (32 chars) workspace = Current User’s current_workspace email = Email Input’s value (lowercased) role = Role Dropdown’s value invited_by = Current User expires_at = Current date/time + 7 days status = Pending Step 2: Send email via SendGrid API Connector to: Invitation’s email subject: “You’ve been invited to [Workspace name]” link: https://app.yoursaas.com/accept-invite?token=[Invitation’s token]
2
Invited user clicks the email link

They land on /accept-invite with the token as a URL parameter. The page loads the Invitation by token. First validate: token exists, status = Pending, expires_at > now. If any check fails, show an appropriate error message.

// accept-invite page: load invitation on page load Page data source: Do a search for Invitations token = URL parameter “token” status = Pending :first item // Show error if invalid / expired Condition: Page’s Invitation is empty OR Page’s Invitation’s expires_at < Current date/time Action: Show “This invitation is invalid or has expired” group
3
Two-path acceptance: existing vs. new user

The page must detect whether the invited email already belongs to a Bubble user. Show either a login form or a sign-up form accordingly. The token must be preserved through both paths.

// Check if invited email has an existing account Page load: Do a search for Users email = Page’s Invitation’s email :count > 0 → store in custom state “user_exists” (yes/no) // Show signup form when user_exists = no // Show login form when user_exists = yes // Pre-fill email field in both forms from invitation // After signup/login, trigger the accept workflow below
4
Accept workflow: create Membership, update everything
// Workflow: “Accept Invitation” (runs after login/signup) Step 1: Only when: Current User’s email = Invitation’s email AND Invitation’s status = Pending AND Invitation’s expires_at > Current date/time Step 2: Create Membership user = Current User workspace = Invitation’s workspace role = Invitation’s role status = Active invited_by = Invitation’s invited_by invited_at = Invitation’s Created Date joined_at = Current date/time Step 3: Update Invitation: status = Accepted, accepted_at = now Step 4: Update Workspace: seats_used + 1 Step 5: Update Current User: current_workspace = Invitation’s workspace Step 6: Navigate to: /dashboard

Stripe Subscription Billing for Multi-Tenant SaaS

In a multi-tenant SaaS, billing is workspace-level, not user-level. One workspace has one Stripe Customer, one active Subscription, and one billing contact (the owner). Every billing action — checkout, upgrade, downgrade, cancellation — is initiated by the workspace owner on behalf of the workspace. Never create a Stripe Customer per user.

SaaS Pricing Strategy Built in Bubble

Starter
Free Trial
$0
14 days, then choose a plan
3 team members maximum
500 records included
Core features only
Community support
Advanced integrations
Priority support
Audit log access
Most Popular
Pro
$99
per workspace / month
Up to 10 team members
10,000 records included
All core features
Advanced integrations
Email support (48h SLA)
Audit log access
API access
Enterprise
Business
$299
per workspace / month
Unlimited team members
Unlimited records
Everything in Pro
SSO / SAML login
Dedicated support (4h SLA)
Custom data retention
SLA & DPA available

The Stripe Integration Architecture

// Required Stripe objects per workspace Stripe Customer → 1 per workspace (not per user) metadata.workspace_id = Bubble Workspace unique id metadata.owner_email = Workspace owner email → store Stripe Customer ID on Workspace record Stripe Subscription → 1 per workspace (max 1 active at a time) customer = Workspace’s stripe_customer_id items[0].price = Plan’s stripe_price_id (monthly or yearly) trial_period_days = Plan’s trial_days (on first subscription) metadata.workspace_id = Bubble Workspace unique id → store Subscription ID on Workspace record // Checkout flow using Stripe Checkout (recommended) Step 1: If no Stripe Customer exists: create one via API Connector POST https://api.stripe.com/v1/customers email = Current User’s email metadata[workspace_id] = Current Workspace’s unique id → update Workspace: stripe_customer_id = response.id Step 2: Create Stripe Checkout Session POST https://api.stripe.com/v1/checkout/sessions mode = “subscription” customer = Workspace’s stripe_customer_id line_items[0].price = Selected Plan’s stripe_price_id success_url = https://app.yoursaas.com/billing/success?session_id={CHECKOUT_SESSION_ID} cancel_url = https://app.yoursaas.com/billing → open response.url in new window

Stripe Webhook Handlers: The Six You Must Build

Stripe webhooks are how your Bubble app stays in sync with billing reality. Do not rely solely on the post-checkout redirect to update subscription status — payment failures, cancellations, and renewals all happen outside the user’s active session. You need webhook handlers for all of these events.

checkout.session.completed
After successful checkout
Find Workspace by metadata.workspace_id. Update: stripe_sub_id, subscription_status = Active, plan = matched Plan record, trial_ends_at if applicable. Send welcome email to workspace owner.
customer.subscription.updated
Plan change, seat change, renewal date change
Find Workspace by metadata.workspace_id from subscription object. Update plan, subscription_status, and any limit fields. Handles upgrades, downgrades, and mid-cycle changes.
invoice.payment_failed
Card declined, bank error, etc.
Find Workspace. Update subscription_status = Past Due. Send payment failure notification email to workspace owner with link to update payment method. After 3 failed attempts (Stripe dunning), subscription.deleted fires.
customer.subscription.deleted
Cancellation (immediate or end-of-period)
Find Workspace. Update subscription_status = Cancelled, plan = Free/null. Do not delete workspace data — preserve it for 30 days. Block creation of new records but keep read access. Send cancellation confirmation email.
customer.subscription.trial_will_end
3 days before trial expiry
Find Workspace. Send trial-ending warning email to workspace owner with upgrade CTA. This is a critical conversion moment — personalise the email with what the workspace has accomplished during trial.
invoice.payment_succeeded
Every successful monthly/annual renewal
Find Workspace. Update subscription_status = Active (corrects any Past Due state). Log renewal to Audit Log. Optional: send renewal receipt email with invoice link from Stripe.
💡

Validate Stripe Webhook Signatures

Any server can send a POST request to your Bubble webhook endpoint claiming to be Stripe. Stripe signs every webhook with a secret you set in the Stripe dashboard. Use the Stripe Webhook Validator plugin (or the API Connector) to verify the Stripe-Signature header on every incoming webhook before processing it. An unvalidated webhook endpoint is a critical security vulnerability that allows anyone to fake billing events.

Usage Limits & Plan Enforcement

Plan limits are what turns your SaaS into a business model. Every plan has limits on seats, records, storage, or feature access. Enforcing these limits correctly is a two-part job: blocking creation when a limit is hit, and displaying current usage to workspace admins so they know when they’re approaching a limit and need to upgrade.

The Seat Limit Pattern

// Check seat limit before sending any invitation Current seat count: Do a search for Memberships [ workspace = Current User’s current_workspace, status = Active ]:count // OR: use the denormalised Workspace’s seats_used field // (faster, but requires keeping it updated on join/leave) Seat limit: Current User’s current_workspace’s plan’s seat_limit Show “Invite” button only when: Memberships:count < Plan’s seat_limit AND Current Role is in [Admin, Owner] Show “Seat limit reached” CTA when: Memberships:count ≥ Plan’s seat_limit → button: “Upgrade Plan” → navigate to /billing // Also enforce in the invitation workflow (server-side) Workflow step 1: Only when Memberships:count < seat_limit Else: Display alert “Seat limit reached”

Record Limit Enforcement

// Check record limit before creating any primary data type // Example: Projects (apply same pattern to every creation action) Current project count: Do a search for Projects [ workspace = Current User’s current_workspace, is_deleted = no ]:count Record limit: Current User’s current_workspace’s plan’s record_limit // -1 or 0 can represent “unlimited” for Business plan Show “New Project” button only when: Plan’s record_limit = 0 (unlimited) OR Projects:count < Plan’s record_limit // Workflow guard Step 1: Only when: Plan’s record_limit = 0 OR Projects:count < Plan’s record_limit

Feature Flags: Gating Features by Plan

Beyond numeric limits, some features should only be accessible on certain plans: API access, advanced exports, SSO, audit logs. Store these as a list of text feature identifiers on the Plan data type, and check membership in that list before showing any feature.

// Plan’s “features” field is a list of text feature keys // Starter: [“core”] // Pro: [“core”, “api_access”, “audit_log”, “advanced_export”] // Business:[“core”, “api_access”, “audit_log”, “advanced_export”, “sso”, “custom_retention”] Show “API Keys” section only when: Current Workspace’s plan’s features contains “api_access” Show “Audit Log” menu item only when: Current Workspace’s plan’s features contains “audit_log” AND Current Role is in [Admin, Owner] // For workflow-level gating (server-side protection) API Workflow “export_data”: Step 1: Only when Workspace’s plan’s features contains “advanced_export” // If this fails, workflow stops — no data exported regardless of UI state

Building the Usage Dashboard for Admins

👥

Seat Usage Meter

Show seats_used / plan’s seat_limit as a progress bar. Colour it amber at 80%, red at 100%. Link directly to the invite page from the meter.

📊

Record Count

Show current record count vs. plan limit for each major data type. Use a server-side search count, not client-side filter. Cache in a custom state on page load to avoid redundant queries.

💰

Next Billing Date

Store and display the subscription renewal date from the Stripe webhook payload. Show alongside the current plan name, monthly cost, and a link to the Stripe Customer Portal for payment method management.

Upgrade Prompts

When any limit is at 80%+ or a locked feature is accessed, show contextual upgrade prompts in-app. These are your highest-converting upgrade moments — far more effective than a pricing page visit.

📝

Billing History

Use the Stripe API Connector to list invoices for the workspace’s Stripe Customer. Display invoice date, amount, and a link to the Stripe-hosted PDF. Never store payment card details in Bubble.

🕐

Trial Countdown

If subscription_status = Trialing, show a prominent trial expiry countdown. Make the trial end date and remaining days visible in the header or sidebar on every page during the trial period.

Workspace Onboarding & First-Run Experience

The onboarding flow is one of the highest-leverage surfaces in your entire SaaS product. It is the moment when a user either understands your product and becomes activated, or gets confused and churns silently. A well-designed Bubble onboarding flow guides new workspace owners through exactly the steps they need to take to reach their first “aha moment” with your product.

The Post-Signup Onboarding Flow

1
Signup → Create Workspace

Immediately after email/password signup, redirect to a workspace creation page — not to the main app. Collect workspace name, optionally slug (auto-generated from name), and upload logo. Create the Workspace record, create the first Membership (role = Owner, status = Active), set User’s current_workspace, then start the trial.

// Workflow: “Create Workspace” (triggered on signup completion) Step 1: Create Workspace name = Name Input’s value slug = auto-generated (name lowercased, spaces to hyphens) owner = Current User plan = Free Trial Plan record subscription_status = Trialing trial_ends_at = Current date/time + 14 days seats_used = 1 Step 2: Create Membership user = Current User, workspace = Result of Step 1 role = Owner, status = Active, joined_at = now Step 3: Update User: current_workspace = Result of Step 1 Step 4: Navigate to /onboarding
2
Guided Setup Checklist

A multi-step onboarding checklist page that walks the owner through 3–5 essential setup steps. Store completion state as yes/no fields on the Workspace or User record. Show a progress bar. Each completed step triggers the next one to become active.

1⃣

Profile setup

Add display name, profile photo, and notification preferences. Store on User record.

2⃣

Invite first teammate

Prompt to send the first invite. Products with multi-user activation have dramatically better retention.

3⃣

Create first record

Guide the user to create their first Project, Contact, or whatever the core entity is in your product.

4⃣

Connect integrations

If your product connects to external tools, this is where to prompt that setup. One integration connected = 40% higher retention.

3
The Aha Moment Gate

Define the specific moment when a user has experienced enough of your product to understand its core value. In Asana: “complete your first task.” In Slack: “send a message in a channel with a teammate.” Build your onboarding flow to drive every new user to that moment within the first session. Track it as an event in your analytics system. Users who reach the aha moment in session 1 retain at 3–5× the rate of those who don’t.

The Workspace Switcher

Users who belong to multiple workspaces need a way to switch between them without logging out. The workspace switcher is a dropdown in the app header that shows all workspaces the current user is an active member of, and switches context instantly.

// Workspace switcher: data source and switch workflow Repeating Group (workspace list): Data type: Membership Do a search for Memberships [ user = Current User, status = Active ] sort by: Created Date ascending // Each row shows: Current cell’s Membership’s workspace’s logo // Current cell’s Membership’s workspace’s name // Current cell’s Membership’s role // Highlight current: conditional on workspace = Current User’s current_workspace On click (switch workspace): Step 1: Update User: current_workspace = Current cell’s Membership’s workspace Step 2: Navigate to /dashboard (page reload clears all custom states) // “Create new workspace” option at the bottom of the list // navigates to /create-workspace with no parameters

Production Launch Checklist & Security Hardening

Before your multi-tenant SaaS goes live, work through this comprehensive checklist. Every item here represents a failure mode we’ve seen in real Bubble SaaS products. A single missed item can result in a data breach, a billing failure, or a broken user experience for paying customers.

🔒 Data Isolation & Privacy

  • Every data type has a privacy rule — no type is left on “Everyone”
  • Every app data type has a workspace field populated on creation
  • Every search in every workflow and page uses a workspace constraint
  • Privacy rules tested with two separate test accounts in different workspaces — confirm Account A cannot see Account B’s data
  • User data type privacy rule: users can only see other users who share a workspace with them
  • Audit log is restricted to Admin and Owner roles only
  • File uploads: confirm uploaded files are associated with workspace and not publicly accessible via direct URL

👥 Role & Permission System

  • Every destructive workflow has a server-side role check (Step 1: Only when) — not just UI-level button hiding
  • Owner role is non-transferable without explicit “Transfer Ownership” workflow that includes confirmation
  • Workspace deletion requires Owner role AND explicit confirmation (typed workspace name)
  • Member removal removes their Membership record AND decrements seats_used on Workspace
  • Viewer role correctly blocked from all create/edit/delete workflows, not just UI elements

💰 Billing Integrity

  • Stripe webhooks validated with signature verification on every event
  • All six critical webhook events handled (checkout, updated, deleted, failed, trial_will_end, payment_succeeded)
  • Post-checkout redirect does NOT update subscription status — only webhook updates it (redirect can be spoofed)
  • Cancelled workspaces have read-only access, data preserved for 30 days
  • Seat limits enforced both in UI and in server-side invitation workflow
  • Record limits enforced both in UI and in server-side creation workflows
  • Tested plan downgrade path: workspace exceeding new plan limits shows degraded state, not error

✉ Invitation System

  • Invitation tokens are cryptographically random (not sequential IDs, not guessable)
  • Invitations expire after 7 days — expired invitations cannot be accepted
  • Accept workflow validates that accepting user’s email matches invitation email exactly
  • Tested both paths: existing user accepts, new user signs up and accepts
  • Duplicate invitation guard: check for existing active Membership before creating another
  • Admin can revoke pending invitations before they are accepted

⚡ Performance & Scale

  • Every search uses database-level constraints, never client-side :filtered by
  • Large lists paginated (page size 15–25, load more on scroll)
  • Frequently-accessed counts (seats_used, record_count) stored as denormalised fields on Workspace, updated on change
  • Static data (plan names, feature flags) stored in Option Sets, not database types
  • Dashboard load time tested with 500+ records in workspace — under 2 seconds
  • Bubble app on at least Growth plan for dedicated server capacity (not shared Starter infrastructure)
The most common failure mode, stated plainly: A Bubble SaaS gets its first 10 paying customers. Then a workspace owner emails support saying they can see another company’s data. Investigation reveals a privacy rule was left on “Everyone” on one data type during early development and never restricted. Now the builder has to fix it under the eyes of paying customers and notify affected workspaces of a data breach. This scenario is entirely preventable by completing a privacy rule audit before going live, testing with two isolated accounts, and never shipping without confirming that each account type can only see what it should. Do this audit. Every time. Before launch.

Recommended Bubble Plugins for Multi-Tenant SaaS

Plugin / ToolUse CaseNotes
Stripe.js by ZeroqodeStripe payment elements, card input, Customer Portal redirectPreferred over raw API Connector for Stripe card UI components
API Connector (built-in)Stripe API calls, SendGrid, Slack, any REST integrationUse for all webhook payloads and API calls not covered by plugins
SendGrid plugin / APITransactional emails: invitations, billing alerts, welcome seriesPrefer dynamic templates for invite and billing emails; easier to iterate
Toolbox pluginRun JavaScript in workflows, generate UUIDs, execute arbitrary JSEssential for generating secure random tokens for invitations
Air Date/Time PickerDate inputs with timezone awarenessMulti-tenant SaaS often has users across timezones — handle dates carefully
Intercom / Crisp pluginIn-app customer support chat, user identificationPass workspace ID and plan as user attributes for segmented support
Segment / Mixpanel pluginProduct analytics, funnel tracking, feature usageTrack workspace_id as a group property for workspace-level analytics
Postmark / Mailgun pluginReliable transactional email delivery alternative to SendGridPostmark has better delivery rates for invitation and billing emails

Build Your Multi-Tenant SaaS the Right Way

Architecture reviews, data model design, Stripe integration, and full SaaS builds —
the same disciplined approach applied to every Bubble product we ship.

Book a Free Discovery Call → View Our Work