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.

Inside Fizzy's Authentication Architecture: A Three-Tier Approach to Multi-Tenant SaaS
Photo by Owen / Unsplash

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:

  1. 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.
  2. All data must be scoped to an Account - Every board, card, and comment belongs to exactly one tenant.
  3. 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 :users relationship, 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:

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

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:

  1. Extract the Account ID from the URL path
  2. Find the corresponding User record for your Identity within that Account
  3. 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:

  1. When Current.session is set (during authentication), it automatically sets Current.identity
  2. When Current.identity is set, it looks up the appropriate Current.user by 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:

  1. initialize captures Current.account as an instance variable
  2. serialize stores it as a GlobalID in the job payload
  3. deserialize restores it when the job is loaded from the queue
  4. perform_now wraps execution in Current.with_account to 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 concerns
Current.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.