Inside Fizzy's Authentication Architecture: A Three-Tier Approach to Multi-Tenant SaaS
How 37signals' Fizzy solves multi-tenant authentication with a three-tier Identity→User→Account model, enabling one person to belong to multiple organizations.
When 37signals open-sourced Fizzy, their collaborative project management tool, they revealed an elegant solution to one of the trickiest problems in SaaS application design: how to handle authentication in a multi-tenant environment where users can belong to multiple organizations. Rather than using complex namespace routing or subdomain-based tenancy, Fizzy implements a clean three-tier authentication model that separates global identity from tenant-specific users.
This architecture is worth studying because it demonstrates how thoughtful modeling of domain concepts can simplify what might otherwise require heavyweight infrastructure. Let's dive into how it works.
The Multi-Tenancy Challenge
Fizzy uses URL path-based multi-tenancy. Each organization (called an "Account" in Fizzy) gets a unique numeric identifier that appears in every URL:
https://fizzy.do/1234567/boards/new
https://fizzy.do/1234567/cards/42
That seven-digit number is the Account's external_account_id. This approach has significant implications for authentication:
- A person can belong to multiple Accounts - You might be on your company's account, a client's account, and a side project's account.
- All data must be scoped to an Account - Every board, card, and comment belongs to exactly one tenant.
- The application needs to know which Account context you're operating in - The same HTTP request that authenticates you must also determine your tenant.
The naive approach would be to create separate user credentials for each Account, but that's a poor user experience. Instead, Fizzy separates the global authentication identity from the account-specific user profile through a three-tier model.
The Three-Tier Model: Identity → User → Account
Fizzy's authentication architecture has three key entities:
1. Identity - The Global Authentication Layer
An Identity represents a person's global authentication credentials. It's the only entity that exists outside the multi-tenant boundary:
class Identity < ApplicationRecord
has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
has_many :users, dependent: :nullify
has_many :accounts, through: :users
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
end
Key characteristics:
- Email-based: Each Identity is tied to a unique email address
- Tenant-agnostic: Identities don't belong to any Account
- Authentication owner: Sessions, magic links, and access tokens all belong to the Identity
- Gateway to Accounts: Through the
has_many :usersrelationship, an Identity can access multiple Accounts
The database schema reflects this independence:
create_table "identities" do |t|
t.string "email_address", null: false
t.boolean "staff", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email_address"], unique: true
end
Notice there's no account_id foreign key here. Identities are truly global.
2. Account - The Tenant
An Account is the multi-tenant boundary. It owns all application data:
class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :boards, dependent: :destroy
has_many :cards, dependent: :destroy
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
before_create :assign_external_account_id
def slug
"/#{AccountSlug.encode(external_account_id)}"
end
end
Each Account receives a unique external_account_id on creation (a 7+ digit number), which becomes its URL slug. This ID is the tenant discriminator for the entire application.
3. User - The Bridge Between Identity and Account
Here's where the architecture gets interesting. A User is not your authentication credential. Instead, it's your membership in a specific Account:
class User < ApplicationRecord
belongs_to :account
belongs_to :identity, optional: true
validates :name, presence: true
enum :role, %i[ owner admin member system ].index_by(&:itself)
end
The schema makes the relationship crystal clear:
create_table "users" do |t|
t.uuid "account_id", null: false
t.uuid "identity_id"
t.string "name", null: false
t.string "role", default: "member", null: false
t.boolean "active", default: true, null: false
t.datetime "verified_at"
t.index ["account_id", "identity_id"], unique: true
end
The unique index on [account_id, identity_id] enforces a crucial constraint: one Identity can have at most one User per Account. This prevents duplicate memberships while allowing the same person (Identity) to have different User profiles across multiple Accounts.
Notice also that identity_id is nullable. This allows for "orphaned" Users - placeholder records that can later be claimed when someone joins via a different flow.
Why This Design Works
This three-tier separation provides several benefits:
Single Sign-On Across Accounts: You authenticate once with your Identity, then the system resolves which User profile to use based on the Account in the URL.
Flexible Membership Models: The same person can be:
- An owner in their company's Account
- A member in a client's Account
- An admin in their side project's Account
Account Isolation: All sensitive data (boards, cards, webhooks) belongs to Accounts, never directly to Identities. This makes tenant isolation straightforward.
Graceful User Management: When an Identity is deleted, you can choose to either delete or deactivate their User records across all Accounts. Fizzy deactivates them:
class Identity < ApplicationRecord
before_destroy :deactivate_users, prepend: true
private
def deactivate_users
users.find_each(&:deactivate)
end
end
class User < ApplicationRecord
def deactivate
transaction do
accesses.destroy_all
update! active: false, identity: nil
end
end
end
This preserves historical data (comments, cards created) while revoking access.
Passwordless Authentication Flow
Fizzy uses magic link authentication, which fits elegantly with the Identity-based model. Here's the flow:
1. Request Magic Link
When you visit the sign-in page and enter your email:
class SessionsController < ApplicationController
def create
if identity = Identity.find_by(email_address: email_address)
sign_in identity
elsif Account.accepting_signups?
sign_up
else
redirect_to_fake_session_magic_link email_address
end
end
private
def sign_in(identity)
redirect_to_session_magic_link identity.send_magic_link
end
end
The send_magic_link method creates a MagicLink record and emails it:
class Identity < ApplicationRecord
def send_magic_link(**attributes)
magic_links.create!(attributes).tap do |magic_link|
MagicLinkMailer.sign_in_instructions(magic_link).deliver_later
end
end
end
2. Consume Magic Link
When you click the magic link in your email, it contains a one-time code that gets verified:
class Sessions::MagicLinksController < ApplicationController
def create
if magic_link = MagicLink.consume(code)
authenticate magic_link
else
invalid_code
end
end
private
def authenticate(magic_link)
if ActiveSupport::SecurityUtils.secure_compare(
email_address_pending_authentication || "",
magic_link.identity.email_address
)
sign_in magic_link
else
email_address_mismatch
end
end
def sign_in(magic_link)
clear_pending_authentication_token
start_new_session_for magic_link.identity
redirect_to after_sign_in_url(magic_link)
end
end
Notice the authentication creates a Session record that belongs to the Identity:
class Session < ApplicationRecord
belongs_to :identity
end
Your browser receives a signed session cookie that identifies your Identity. At this point, you're authenticated globally but not yet operating in any Account context.
Multi-Tenancy Through Request Context
Here's where Fizzy's architecture really shines. Once you're authenticated as an Identity, the application needs to:
- Extract the Account ID from the URL path
- Find the corresponding User record for your Identity within that Account
- Make both available throughout the request lifecycle
This is handled by middleware and request-scoped attributes.
Extracting the Account from the URL
The AccountSlug::Extractor middleware intercepts every request:
module AccountSlug
PATTERN = /(\d{7,})/
PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/
class Extractor
def call(env)
request = ActionDispatch::Request.new(env)
if request.path_info =~ PATH_INFO_MATCH
# Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
# Stash the account's external ID
env["fizzy.external_account_id"] = AccountSlug.decode($2)
end
if env["fizzy.external_account_id"]
account = Account.find_by(external_account_id: env["fizzy.external_account_id"])
Current.with_account(account) do
@app.call env
end
else
Current.without_account do
@app.call env
end
end
end
end
def self.decode(slug) slug.to_i end
def self.encode(id) "%07d" % id end
end
This is clever: instead of making Rails aware of the account prefix in routes, the middleware moves it from PATH_INFO to SCRIPT_NAME. To Rails, it appears the application is "mounted" at /1234567, so all route helpers automatically include the prefix.
For example, when you call card_path(@card) within an Account context, Rails generates /1234567/cards/42 automatically.
Current Request Attributes
The middleware sets Current.account, which is a thread-local request attribute:
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
def session=(value)
super(value)
if value.present?
self.identity = session.identity
end
end
def identity=(identity)
super(identity)
if identity.present?
self.user = identity.users.find_by(account: account)
end
end
end
Notice the cascading assignment logic:
- When
Current.sessionis set (during authentication), it automatically setsCurrent.identity - When
Current.identityis set, it looks up the appropriateCurrent.userby finding the User record that matches both the Identity and the current Account
This means throughout your request, you have access to:
Current.account- The tenant context (extracted from URL)Current.identity- Who you are globally (from session)Current.user- Your Account-specific profile (the join of the above two)
Controllers and models can reference these without dependency injection:
class Cards::CommentsController < ApplicationController
def create
@comment = @card.comments.create!(
comment_params.merge(creator: Current.user)
)
end
end
Automatic Account Scoping in Models
Because every multi-tenant model includes account_id, Fizzy can enforce data isolation at the model level:
# config/application.rb
module Fizzy
class Application < Rails::Application
config.active_record.automatic_scope_inversing = true
end
end
Most models include a concern:
module MultiTenantable
extend ActiveSupport::Concern
included do
belongs_to :account
default_scope { where(account: Current.account) }
end
end
This means queries like Card.find(params[:id]) automatically scope to Current.account. You can't accidentally access another tenant's data.
Background Jobs and Account Context
Background jobs present a challenge for multi-tenant applications: how do you preserve the Account context when a job executes minutes or hours after the request that enqueued it?
Fizzy solves this by automatically serializing and restoring Current.account:
module FizzyActiveJobExtensions
extend ActiveSupport::Concern
prepended do
attr_reader :account
end
def initialize(...)
super
@account = Current.account
end
def serialize
super.merge({ "account" => @account&.to_gid })
end
def deserialize(job_data)
super
if _account = job_data.fetch("account", nil)
@account = GlobalID::Locator.locate(_account)
end
end
def perform_now
if account.present?
Current.with_account(account) { super }
else
super
end
end
end
ActiveSupport.on_load(:active_job) do
prepend FizzyActiveJobExtensions
end
This extension is prepended to all ActiveJob classes. When a job is enqueued:
initializecapturesCurrent.accountas an instance variableserializestores it as a GlobalID in the job payloaddeserializerestores it when the job is loaded from the queueperform_nowwraps execution inCurrent.with_accountto restore the request context
This means you can write jobs without worrying about tenancy:
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now # Current.account is automatically set
end
end
The job automatically runs with the same Account context as the request that enqueued it.
Authorization: Roles and Access Control
Authentication determines who you are. Authorization determines what you can do. Fizzy builds authorization on top of its authentication model through two mechanisms: User roles and Board-level Access records.
User Roles
Each User has a role within their Account:
module User::Role
extend ActiveSupport::Concern
included do
enum :role, %i[ owner admin member system ].index_by(&:itself)
scope :owner, -> { where(active: true, role: :owner) }
scope :admin, -> { where(active: true, role: %i[ owner admin ]) }
scope :member, -> { where(active: true, role: :member) }
scope :active, -> { where(active: true, role: %i[ owner admin member ]) }
def admin?
super || owner?
end
end
def can_change?(other)
(admin? && !other.owner?) || other == self
end
def can_administer?(other)
admin? && !other.owner? && other != self
end
def can_administer_board?(board)
admin? || board.creator == self
end
end
Roles are hierarchical:
- Owner: Full control of the Account
- Admin: Can manage users and boards (owners are admins too)
- Member: Standard access
- System: Internal automation user
Remember: roles are per-Account. Your Identity might be an owner in one Account and a member in another.
Board-Level Access
Within an Account, Boards can restrict access to specific Users:
class Access < ApplicationRecord
belongs_to :account, default: -> { user.account }
belongs_to :board, touch: true
belongs_to :user, touch: true
enum :involvement, %i[ access_only watching ].index_by(&:itself)
scope :ordered_by_recently_accessed, -> { order(accessed_at: :desc) }
end
An Access record grants a User permission to view and interact with a Board. Boards can be either "all access" (visible to all Account members) or selective (only visible to Users with explicit Access records).
This provides fine-grained control while keeping the model simple: check for Account membership via User, then check for Board access via Access.
Key Takeaways
Fizzy's three-tier authentication architecture demonstrates several important patterns:
1. Separate global identity from tenant-specific context
By splitting Identity and User, Fizzy enables one person to participate in multiple organizations without credential proliferation.
2. Use URL structure to convey tenant context
Path-based multi-tenancy (/{account_id}/boards/...) makes the tenant explicit in every request, simplifying middleware and routing.
3. Leverage request-scoped attributes for cross-cutting concernsCurrent.account and Current.user provide clean access to context without passing parameters through every method call.
4. Preserve context across async boundaries
Background jobs automatically serialize and restore tenant context, making multi-tenancy invisible to job code.
5. Model authorization on top of authentication
User roles and Access records build a flexible permissions system on the foundation of Identity → User → Account.
This architecture works particularly well for:
- B2B SaaS applications where users belong to multiple organizations
- Applications that need strong tenant isolation without database-per-tenant complexity
- Teams that value convention over configuration (vanilla Rails patterns)
The complete implementation is available in Fizzy's open-source repository, where you can see how these patterns play out across controllers, models, jobs, and tests. For teams building multi-tenant Rails applications, it's a master class in modeling domain concepts to solve infrastructure complexity.