Subdomain-Based Multi-Tenancy in Phoenix

Subdomain-Based Multi-Tenancy in Phoenix: An Implementation Guide

Mike Buhot profile picture

Mike Buhot

22 May 2025 · 10 min read

At Alembic, we recently worked with a client who needed a multi-tenant SaaS platform built with Phoenix and Ash Framework. Their requirements posed several interesting technical challenges:

  • The application needed to serve multiple customer organisations (tenants) from a single instance
  • Each organisation required its own branded subdomain: <org_slug>.example.com.
  • Users could belong to multiple organisations and needed to switch between them easily
  • Switching between organisations shouldn't require re-authentication

While multi-tenancy can be implemented in various ways, we determined that a subdomain-per-tenant approach and schema-per-tenant database design would provide the best user experience and clearest separation for our clients’ needs.

However, this approach introduced several technical hurdles that aren't immediately obvious when building Phoenix applications. In this post, we'll share our implementation strategy, focusing on four key aspects:

  1. Database setup with tables in the public schema and per-tenant schemas
  2. Configuring authentication cookies to work across subdomains
  3. Setting up Content Security Policy (CSP) for proper subdomain navigation
  4. Implementing tenant resolution and user membership verification

The solution we developed provides a seamless experience for users while maintaining proper security boundaries. We hope this implementation guide helps other Elixir developers facing similar client requirements.

Database design: Schema-per-tenant implementation

In our client's application, we implemented a schema-per-tenant approach for data separation between organisations. This architectural decision was practical for our specific use case: the client needed to import data from multiple existing databases, and maintaining separate schemas made this migration process significantly more manageable.

Thanks to Ash Framework's built-in support for multi-tenancy, implementing this pattern was straightforward. For an introduction to Ash's simpler attribute-based approach, see our guide on multitenancy in Ash Framework. The same design would apply when using Ecto schemas by setting the @schema_prefix to "public" on the shared tables. Here's how we structured the database:

Public schema: Cross-tenant resources

The public schema houses resources that need to be accessible across all tenants such as Org:

defmodule MyApp.Accounts.Org do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Postgres

  postgres do
    table "orgs"
    repo MyApp.Repo

    # Automatic tenant schema creation
    manage_tenant do
      template ["org_", :slug]
      create? true
      update? false
    end
  end

# Resource definition...

end

Similarly, users and memberships live in the default public schema:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Postgres

  postgres do
    table "users"
    repo MyApp.Repo
  end

# Resource definition...
end

Tenant schemas: Organisation-specific data

For organisation-specific resources, we explicitly opt into schema-based multi-tenancy using Ash's :context strategy:

defmodule MyApp.Contacts.Contact do
  use Ash.Resource,
    data_layer: Ash.DataLayer.Postgres

  postgres do
    table "contacts"
    repo MyApp.Repo
  end

  multitenancy do
    strategy :context
  end

# Resource definition...
end

Automatic schema management

With the manage_tenant configuration in the Org resource, Ash automatically creates a dedicated schema following our naming template (org_#{slug}) whenever a new organisation is registered. How convenient!

With this database architecture in place, we could focus on the web layer implementation, which needed to properly route requests to the appropriate tenant context based on the subdomain.

Local development environment setup

During implementation, we needed a way to test the subdomain functionality locally without deploying to a staging server. This required configuring our development environment to support multiple subdomains pointing to our localhost. We modified our /etc/hosts file to map several test domains to our local IP address:

127.0.0.1 myapp.local tenant1.myapp.local tenant2.myapp.local 

This simple configuration allowed us to access the main application at http://myapp.local:4000, test tenant subdomains like http://tenant1.myapp.local:4000 and verify the cookie sharing and tenant resolution logic across domains

With our local development environment configured, we could begin implementing the core components of the subdomain-based multi-tenancy system.

Handling authentication cookies across subdomains

The first major challenge we encountered was managing authentication cookies across multiple subdomains. Without proper configuration, Phoenix's default cookie behaviour won’t work out of the box.

The cookie domain problem

By default, cookies are set specifically for the domain where they're created. Consider these scenarios:

  1. A user signs in at example.com and receives an authentication cookie. When they navigate to tenant1.example.com, the browser treats this as a different domain, so the authentication cookie isn't sent, and the user appears logged out.
  2. Similarly, if a user authenticates while on tenant1.example.com and then visits tenant2.example.com, they'll need to log in again despite being on the same application.
  3. Even worse, if a user moves between the root domain and various tenant subdomains, they could end up with multiple authentication cookies, creating confusion about which tenant they're currently using.

These behaviours are technically correct from a browser security perspective but create a poor user experience when we want seamless navigation across tenants.

Modifying the Phoenix endpoint

To solve this problem, we needed to modify how Phoenix sets session cookies:

defmodule MyAppWeb.Endpoint do
  # Regular endpoint configuration...
  @session_options [
    store: :cookie,
    key: "_myapp_key",
    signing_salt: "SomeSalt",
    same_site: "Lax"
  ]

# Instead of directly using @session_options in Plug.Session
# We route through a function to customise the domain
  plug :session
  plug MyAppWeb.Router

  def session(conn, _opts) do
    opts = session_opts()
    Plug.Session.call(conn, Plug.Session.init(opts))
  end

  def session_opts() do
    Keyword.put(@session_options, :domain, MyAppWeb.Endpoint.host())
  end
end

There are a few technical details worth noting in this implementation:

  1. Since the host can be set by an environment variable, we needed to delay the evaluation of the domain. Moving the Plug.Session.init call into a function lets us determine the host at runtime rather than compile time.
  2. The function sets the cookie domain to the top-level domain (e.g., example.com instead of tenant1.example.com), which ensures the cookie is sent with requests to any subdomain.
  3. For high-traffic production environments, we would consider optimising this by caching the session options in :persistent_term

For LiveView applications, we also need to ensure that the same session configuration applies to the WebSocket connection. This requires modifying the LiveView socket configuration to use our session_opts/0 function:

socket "/live", Phoenix.LiveView.Socket,
  websocket: [connect_info: [session: {__MODULE__, :session_opts, []}]]

This ensures that both regular HTTP requests and LiveView WebSocket connections use the same session cookie configuration, maintaining consistent authentication across all aspects of the application.

With these changes, users could authenticate once and then navigate between different tenant subdomains without being repeatedly prompted to log in.

Content security policy configuration for subdomain navigation

Another technical challenge we faced was properly configuring Content Security Policy (CSP) to work with our multi-tenant subdomain architecture. CSP is a critical security feature that helps prevent cross-site scripting (XSS) attacks by restricting which resources can be loaded and executed by the browser.

The CSP subdomain challenge

When implementing subdomain-based multi-tenancy, we encountered an issue with our existing CSP configuration. After a user logged in at the root domain (e.g., example.com), they often needed to be redirected to their specific tenant subdomain (e.g., tenant1.example.com). However, the CSP form_action directive only permits redirects to the exact same domain where the form was submitted.

As a result, when users authenticated on the root domain, the browser would block the subsequent redirect to their tenant subdomain, breaking our authentication flow.

Implementing a subdomain-friendly CSP

To solve this issue, we modified our CSP configuration to explicitly allow redirections to any subdomain of our application:

def content_security_policy(conn, _opts) do
  [_, url_host] = MyAppWeb.Endpoint.url() |> String.split("://", parts: 2)

  ContentSecurityPolicy.Plug.Setup.call(conn,
    default_policy: %ContentSecurityPolicy.Policy{
      default_src: ["'self'"],
      script_src: ["'self'"],
      connect_src: ["'self'", "wss:"],
      style_src: ["'self'", "'unsafe-inline'"],
      img_src: ["'self'", "data:"],
      font_src: ["'self'"],
      form_action: [
        "'self'",
        "*.#{url_host}"# Allow directing to subdomains
      ],
      frame_ancestors: ["'none'"]
    }
  )
end

The important addition here is the wildcard subdomain pattern "*.#{url_host}" in the form_action directive. This pattern dynamically constructs a rule based on the application's base URL, allowing form submissions to result in redirects to any subdomain of our main domain.

For example, if our application's base URL is example.com, this configuration allows redirects to tenant1.example.comtenant2.example.com, or any other subdomain, while still blocking redirects to external domains.

This modification maintains the security benefits of CSP while enabling the seamless subdomain navigation required for our multi-tenant architecture.

Routing and tenant resolution

With our cookie handling and CSP configured, the next step was implementing the tenant resolution logic within our routing pipeline. This ensures that each request is properly associated with the correct tenant before it reaches a controller or LiveView.

Configuring the router pipeline

First, we needed to add our tenant resolution plug to the :browser pipeline in our router:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use AshAuthentication.Phoenix.Router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :content_security_policy
    plug :load_from_session # Ash Authentication plug, sets :current_user assign
    plug MyAppWeb.SetTenant # Set :current_tenant based on user and subdomain
  end

# Router paths defined here...
end

Note the order of the plugs: first we load the authenticated user with :load_from_session (provided by Ash Authentication), then we determine the tenant with MyAppWeb.SetTenant. This ordering is required because tenant determination depends on the current user.

Creating a tenant resolution plug

With the router pipeline established, we implemented a SetTenant plug to determine which tenant a request belongs to:

defmodule MyAppWeb.SetTenant do
  @moduledoc """
  Establishes the current_tenant on the Conn.
  """
  @behaviour Plug

  def init([]), do: []

# User authenticated
def call(conn, _opts) when is_struct(conn.assigns.current_user) do
    user = conn.assigns.current_user |> Ash.load!([:orgs], actor: current_user)
    slug = tenant_slug(conn)
    tenant = choose_tenant(user, slug)

    conn
    |> Plug.Conn.assign(:current_tenant, tenant)
    |> Ash.PlugHelpers.set_tenant(org)
  end

  # No user
  def call(conn, _opts) do
    conn |> Plug.Conn.assign(:current_tenant, nil)
  end

  defp tenant_slug(conn) do
    # Extract the subdomain if there is one
    case String.split(conn.host, ".") do
      [subdomain | rest] when length(rest) >= 2 -> subdomain
      _ -> nil
    end
  end

  # Not on a subdomain
  defp choose_tenant(user, nil) do
    case user.orgs do
      [org] -> org# for convenience, if a user belongs to a single org, use it
      _ -> nil# otherwise ambiguous, don't set a tenant until user is on a subdomain
    end
  end

  # On a subdomain, use it to set the current tenant if the user is a member
  defp choose_tenant(user, slug) do
    user.orgs |> Enum.find(fn org -> org.slug == slug end)
  end
end

This plug extracts the subdomain from the request host and attempts to match it against organisations the user belongs to. For convenience, when not on a subdomain, it automatically selects the user's organisation if they belong to exactly one. This ensures that each request is associated with the appropriate tenant context based on both the URL and the user's permissions.

Protecting routes based on authentication and tenant access

With tenant resolution in place, we needed to implement proper route protection to ensure that:

  1. Public routes are accessible to everyone
  2. Some routes are accessible only to authenticated users
  3. Tenant-specific routes are accessible only to users who are members of that tenant

Configuring route protection pipelines

We updated our router to include additional pipeline and live_session blocks for different levels of protection:

defmodule MyAppWeb.Router do
  pipeline :browser do
    # ... as above
  end

  pipeline :require_user do
    plug MyAppWeb.EnsureAuthenticated, user_required: true, tenant_required: false
  end

  pipeline :require_tenant do
    plug MyAppWeb.EnsureAuthenticated, user_required: true, tenant_required: true
  end

  # Public routes
  scope "/", MyAppWeb do
    pipe_through :browser

    get "/login", AuthController, :index
    post "/login", AuthController, :login
  end

  # User-authenticated routes that don't need a tenant
  scope "/", MyAppWeb do
    pipe_through [:browser, :require_user]

    get "/orgs", OrganisationController, :index
    post "/orgs", OrganisationController, :choose
    get "/account", AccountController, :show
  end

  # Routes requiring both user and tenant
  scope "/", MyAppWeb do
    pipe_through [:browser, :require_tenant]

    # Controller Routes
    get "/", DashboardController, :index
    resources "/projects", ProjectController

    # LiveView Routes 
    ash_authentication_live_session :authentication_and_tenant_required,
        on_mount: [
          {MyAppWeb.EnsureAuthenticated, :live_user_required},
          {MyAppWeb.EnsureAuthenticated, :live_tenant_required}
        ] do
        live "/contacts", ContactsLive, :index
        # ...more live views...
      end
  end
end

This approach creates a clear separation between different types of routes:

  • The first scope contains public routes that anyone can access
  • The second scope requires authentication but doesn't require a tenant (useful for account settings or organisation selection)
  • The third scope requires both authentication and a valid tenant (for actual application functionality)
  • LiveViews are groups into livesession (shown using AshAuthentication’s wrapper macro ashauthenticationlivesession) with onmount hooks ensuring the user.

Implementing the authentication plug

To enforce these requirements, we implemented an EnsureAuthenticated plug with simplified redirection logic:

defmodule MyAppWeb.EnsureAuthenticated do
  @behaviour Plug

  # Plug Implementation
    @impl Plug
  def init(opts) do
    Keyword.validate!(opts, [:user_required, :tenant_required]) |> Map.new()
  end

    @impl Plug
  def call(conn, opts) do
    with :ok <- user_required(conn.assigns, opts),
         :ok <- tenant_required(conn.assigns, opts) do
      conn
    else
      {:error, :no_user} ->
        redirect(conn, "/login")
      {:error, :no_tenant} ->
        redirect(conn, "/orgs")
    end
  end

  # Mount Hooks for LiveViews
  def on_mount(:live_user_required, _params, _session, socket) do
    case user_required(socket.assigns, %{user_required: true}) do
      :ok -> {:cont, socket}
      {:error, :no_user} ->
        {:halt, Phoenix.LiveView.redirect(socket, to: "/login")}
    end
  end

  def on_mount(:live_tenant_required, _params, _session, socket) do
    case tenant_required(socket.assigns, %{tenant_required: true}) do
      :ok -> {:cont, socket}
      {:error, :no_tenant} ->
        {:halt, Phoenix.LiveView.redirect(socket, to: "/orgs")}
    end
  end

  defp user_required(assigns, %{user_required: true}) do
    if assigns[:current_user], do: :ok, else: {:error, :no_user}
  end

  defp user_required(_conn, _opts) do
    :ok
  end

  defp tenant_required(assigns, %{tenant_required: true}) do
    if assigns[:current_tenant], do: :ok, else: {:error, :no_tenant}
  end

  defp tenant_required(_conn, _opts) do
    :ok
  end

  defp redirect(conn, to) do
    conn
    |> Phoenix.Controller.redirect(to: to)
    |> Plug.Conn.halt()
  end
end

With this implementation, we ensured that users could only access tenant-specific routes when authenticated and having access to the requested tenant, creating clear security boundaries between different parts of the application. We’ve found this pattern of using a single module as both a Plug and a LiveView hook to be effective in keeping behaviour consistent between Controller requests and LiveView requests.

Creating the organisation selection interface

With our authentication and tenant resolution components in place, we needed a way for users to select an organisation when they:

  1. Log in for the first time and belong to multiple organisations
  2. Attempt to access a tenant subdomain they don't have access to
  3. Visit the root domain without a tenant being selected

Building the organisation selection UI

We created a dedicated controller and view to present users with their available organisations:

defmodule MyAppWeb.OrgsController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    user = conn.assigns.current_user
    render(conn, "index.html", orgs: user.orgs)
  end

  def choose(conn, %{"org" => org_slug}) do
    url = 
      conn
      |> MyAppWeb.Router.Helpers.dashboard_url(:index)
      |> subdomain(org_slug)

    conn
    |> redirect(external: url)
  end

  defp subdomain(url, slug) do
    uri = URI.parse(url)
    host_parts = String.split(uri.host, ".")
    domain = Enum.take(host_parts, -2) |> Enum.join(".")
    %{uri | host: "#{slug}.#{domain}"} |> URI.to_string()
  end
end

The controller consists of two actions:

  1. index/2 - Lists all organisations the user has access to
  2. choose/2 - Handles the organisation selection and redirects to the tenant subdomain using the external: option, providing a full URL rather than the usual ~p style path.

The subdomain/2 helper function prefixes the subdomain to the host of the URL.

This approach ensures that after selecting an organisation, the user is redirected to the appropriate subdomain URL, triggering the tenant resolution process we implemented earlier.

The redirect flow

When a user selects an organisation, several things happen:

  1. The user is redirected to the tenant's subdomain
  2. Upon reaching the subdomain, our SetTenant plug resolves the tenant from the subdomain
  3. The request proceeds to the tenant-specific route with the appropriate tenant context

This flow ensures that users always access tenant-specific resources within the correct subdomain, providing a clear visual indicator of which organisation they're currently working with.

Conclusion

Building a subdomain-based multi-tenant application in Phoenix requires addressing several interconnected challenges. In this blog post, we've shared our implementation approach for a client project, focusing on the key components required for a seamless multi-tenant experience:

  1. Database Structure: We implemented schema-per-tenant isolation using Ash Framework's built-in multi-tenancy support, providing strong data separation between organisations.
  2. Cookie Management: We modified the Phoenix endpoint to set cookies at the top-level domain, ensuring authentication persists across tenant subdomains and eliminating repeated logins.
  3. Content Security Policy: We updated the CSP configuration to allow form-based redirects between subdomains, maintaining security while enabling cross-tenant navigation.
  4. Tenant Resolution: We created a plug that determines the current tenant based on the subdomain and verifies the user has access to it, with fallbacks to session or default tenant when appropriate.
  5. Route Protection: We implemented pipelines for different authorisation levels, ensuring that tenant-specific routes are only accessible to authenticated users with appropriate permissions.
  6. Organisation Selection: We built a UI for users to select and switch between their available organisations, with automatic redirection to the corresponding subdomain.

This architecture has proven effective for our client's needs, providing a clean separation between tenants while maintaining a seamless user experience. The URL structure with tenant-specific subdomains gives users clear visual feedback about which organisation context they're currently working in.

While this implementation covers the core components needed for subdomain-based multi-tenancy, each application may require additional considerations. Depending on your specific requirements, you might need to address other aspects such as asset storage namespacing, background job segregation, or tenant-specific configuration.

At Alembic, we've found that this approach strikes a good balance between security, usability, and maintainability for multi-tenant Phoenix applications. We hope this implementation guide helps other developers facing similar challenges in their projects.

Mike Buhot profile picture

WRITTEN BY

Mike Buhot

Hi, I’m Mike! I'm a functional programming enthusiast. When I'm not building awesome software I'm probably enjoying the outdoors with my kids or trying a new low-and-slow barbeque recipe.

Want to read more?

The latest news, articles, and resources, sent to your inbox occasionally.