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:
- Database setup with tables in the public schema and per-tenant schemas
- Configuring authentication cookies to work across subdomains
- Setting up Content Security Policy (CSP) for proper subdomain navigation
- 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:
- A user signs in at
example.com
and receives an authentication cookie. When they navigate totenant1.example.com
, the browser treats this as a different domain, so the authentication cookie isn't sent, and the user appears logged out. - Similarly, if a user authenticates while on
tenant1.example.com
and then visitstenant2.example.com
, they'll need to log in again despite being on the same application. - 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:
- 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. - The function sets the cookie domain to the top-level domain (e.g.,
example.com
instead oftenant1.example.com
), which ensures the cookie is sent with requests to any subdomain. - 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.com
, tenant2.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:
- Public routes are accessible to everyone
- Some routes are accessible only to authenticated users
- 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:
- Log in for the first time and belong to multiple organisations
- Attempt to access a tenant subdomain they don't have access to
- 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:
index/2
- Lists all organisations the user has access tochoose/2
- Handles the organisation selection and redirects to the tenant subdomain using theexternal:
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:
- The user is redirected to the tenant's subdomain
- Upon reaching the subdomain, our
SetTenant
plug resolves the tenant from the subdomain - 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:
- Database Structure: We implemented schema-per-tenant isolation using Ash Framework's built-in multi-tenancy support, providing strong data separation between organisations.
- 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.
- Content Security Policy: We updated the CSP configuration to allow form-based redirects between subdomains, maintaining security while enabling cross-tenant navigation.
- 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.
- Route Protection: We implemented pipelines for different authorisation levels, ensuring that tenant-specific routes are only accessible to authenticated users with appropriate permissions.
- 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.