Phoenix scopes explained: from scoped context to authorization with Permit.Phoenix

Article autor
March 10, 2026
Phoenix scopes explained: from scoped context to authorization with Permit.Phoenix
Elixir Newsletter
Join Elixir newsletter

Subscribe to receive Elixir news to your inbox every two weeks.

Oops! Something went wrong while submitting the form.
Elixir Newsletter
Expand your skills

Download free e-books, watch expert tech talks, and explore open-source projects. Everything you need to grow as a developer - completely free.

Table of contents

Phoenix scopes are a new concept in Phoenix 1.8, and they can be easy to misunderstand at first. What they are, how they fit into Phoenix, and where they relate to authorization - Find out below.

What are Phoenix Scopes?

In Phoenix scope is a data structure that stores information about the current request or session, such as the current user, the user’s organisation or company, permissions, and other metadata that most pages in the application need. In other words, a scope is not just an authentication detail passed around for convenience, but a structured container for the context in which the current request should be interpreted. Phoenix also notes that scopes may include request metadata, such as IP addresses, which reinforces the idea that a scope represents the broader execution context of a request, not just the signed-in user.

The official motivation behind Phoenix Scopes is strongly tied to security and correct data boundaries. Phoenix explicitly highlights broken access control as a major application risk and explains that most data belongs to a specific user, team, or organisation. Because of that, database operations should be properly scoped to the current boundary, rather than assuming that once a user is authenticated, every query is safe by default. This is why Phoenix generators are designed to pass scopes into generated context functions: the framework encourages developers to make access boundaries explicit in the data layer, where they are hardest to forget and easiest to reason about.

Phoenix also presents scopes as a flexible application-level abstraction rather than a one-size-fits-all feature. A project may define more than one scope, choose a default one for generators, and even introduce custom scopes that are not directly tied to a user account. That design makes Phoenix Scopes useful both in freshly generated applications and in more advanced architectures, such as multi-tenant systems, organisation-based products, or apps that need to carry more contextual state than just current_user. The goal isn’t to introduce “yet another Phoenix concept”. Scopes provide a consistent way to move request context safely from the web layer into your domain layer.

Authentication vs scoping vs authorization

Authentication, scoping and authorization are three different concepts that often get conflated. They often appear together in the same request lifecycle, but they solve different problems and should not be treated as interchangeable. Confusing them often leads to unclear application architecture, duplicated checks, and security gaps that only become visible as the codebase grows.

Authentication answers the question: Who is this user? In Phoenix, this is typically the moment when the application verifies identity, for example, through a session, token, or login flow, and determines the currently signed-in user. Phoenix’s authentication generators and request pipeline then make that identity available to the rest of the application, often as the starting point for building a scope. Authentication, however, only proves identity; it does not decide what that user can access.

Scoping answers a different question: what contextual boundary should apply to this request? In Phoenix 1.8+, a scope is a structured container for contextual data such as the current user, organisation, permissions, or other metadata needed by the application. Its main role is to carry that context into business logic and help ensure that queries and CRUD operations stay limited to the correct user, tenant, or organisational boundary. Authentication identifies the actor, while scoping defines the data context in which the actor operates.

Authorisation goes one step further and asks: Is this subject allowed to perform this action on this resource? A user may be authenticated and have a valid scope, but that still does not automatically mean they can edit a record, view another tenant’s data, or perform destructive actions in a LiveView. This is why scopes are extremely useful, but not sufficient on their own for complete access control. Phoenix Scopes provide applications with a strong foundation for passing context safely, while authorisation defines the rules that determine whether a given action is permitted.

Scopes generated by mix phx.gen.auth

One of the most useful aspects of Phoenix 1.8+ Scopes is that you usually do not need to design them from scratch. When you run mix phx.gen.auth, Phoenix generates a default scope automatically and ties it to the authenticated user. According to the official guide, this generated scope is meant to become the standard request context for your application, and the same scope structure is produced for both the --live and --no-live variants of the authentication generator.

The generated scope is intentionally small and straightforward. In Phoenix mix phx.gen.auth generates a small scope struct and a helper function that converts an authenticated user into a consistent “request context” object. The struct stores the current user, and for_user/1 wraps a %User{} into the scope (or returns nil when there is no signed-in user). This gives the rest of the application a single, predictable value to pass into context functions instead of passing current_user around as a standalone argument.

defmodule MyApp.Accounts.Scope do
  alias MyApp.Accounts.User

  defstruct user: nil

  def for_user(%User{} = user) do
    %__MODULE__{user: user}
  end

  def for_user(nil), do: nil
end

This design is important because it establishes a predictable shape for the context that will later be passed into your domain functions. Instead of handing around loose values such as current_user, Phoenix encourages you to pass a single scope struct that can grow with the needs of the application.

Phoenix then wires that scope into the browser pipeline via the fetch_current_scope_for_user plug generated by mix phx.gen.auth. In the router, the plug is added to the :browser pipeline, and in the generated auth module it reads the session token, loads the user, and assigns :current_scope on the connection:

# router.ex
pipeline :browser do
  ...
  plug :fetch_current_scope_for_user
end

# user_auth.ex
def fetch_current_scope_for_user(conn, _opts) do
  {user_token, conn} = ensure_user_token(conn)
  user = user_token && Accounts.get_user_by_session_token(user_token)
  assign(conn, :current_scope, Scope.for_user(user))
end

This is the point at which Phoenix turns the authentication state into a reusable scope. The request no longer carries just a signed-in user; it now carries a structured object that the rest of the application can rely on consistently.

For LiveView, Phoenix generates a matching on_mount hook so that the same scope is available on the socket side as well. The official example uses mount_current_scope to resolve the user from the session and assign the scope lazily with assign_new:

# user_auth.ex
def on_mount(:mount_current_scope, _params, session, socket) do
  {:cont, mount_current_scope(socket, session)}
end

defp mount_current_scope(socket, session) do
  Phoenix.Component.assign_new(socket, :current_scope, fn ->
    user =
      if user_token = session["user_token"] do
        Accounts.get_user_by_session_token(user_token)
      end

    Scope.for_user(user)
  end)
end

This gives controllers and LiveViews the same contextual primitive, a key strength of Phoenix Scopes. Instead of maintaining separate conventions for HTTP and LiveView code, the generated authentication layer provides one common structure for both.

Phoenix also configures this generated scope as the default one for future generators if no default scope has been defined yet. In practice, that means resources generated later with commands such as mix phx.gen.live or mix phx.gen.html will assume that current_scope should be passed into context functions and used to scope data access. The generated authentication setup is therefore not just a login scaffold: it becomes the architectural entry point for secure, scoped application code.

Scopes are not a complete authorisation system

Phoenix Scopes are an excellent foundation for secure application design, but on their own, they do not form a complete authorisation system. The official Phoenix documentation explains scopes as a way to carry request context and to ensure that CRUD operations are properly limited to the current user, team, or organisation. That is a strong architectural default, because it moves access boundaries closer to the data layer and makes scoping harder to forget. But a scope mainly tells the application who the current actor is and what context applies to the request; it does not, by itself, define a complete policy model for deciding which actions are allowed on which resources.

This distinction becomes important as soon as an application moves beyond simple owner-based access rules. In a small app, checking that post.user_id == scope.user.id may be enough. In a larger system, however, access often depends on more than ownership alone: roles, organisations, feature permissions, record state, route intent, and UI interaction patterns all come into play. A user may belong to the correct tenant but still be denied access to edit a record, publish content, approve a workflow, or access an administrative LiveView. Raw scopes help establish boundaries, but developers still need a separate, consistent way to express the rule that determines whether a given action is permitted.

Phoenix Scopes solve an important part of the problem, but not the whole problem. Scopes help you pass around “who is this?” and “which tenant is this?” so you don’t leak data. They don’t decide everything a user is allowed to do. That is exactly why libraries such as Permit.Phoenix exists: not to replace scopes, but to build on top of them by making authorisation more explicit, centralised, and consistent across controllers and LiveView.

How Permit.Phoenix works with Phoenix Scopes

Permit.Phoenix is designed to use Phoenix Scopes as the source of “who is the subject?” when your app is on Phoenix 1.8+. In a typical Phoenix 1.8 setup, authentication assigns :current_scope (not just :current_user) to the conn and to the LiveView socket. Permit.Phoenix can then read that scope and derive the authorisation subject from it, so the rest of the authorisation flow can stay consistent across controllers and LiveView. In the Permit.Phoenix LiveView behaviour, this integration is explicit: when Phoenix Scopes are enabled, Permit maps the current Phoenix scope to the subject, and by default, it uses scope.user as the subject.

In LiveView, the integration is controlled via the use_scope?/0 callback. When use_scope?/0 is enabled, Permit.Phoenix will use the :current_scope assign (instead of :current_user) for subject resolution, which is especially useful in multi-tenant apps or whenever your “subject” needs more context than a single user struct. The behaviour also exposes scope_subject/1, which lets you override how the subject is extracted from the scope; by default, it resolves to the user inside the scope.

On the controller side, Permit.Phoenix follows the same principle: it can perform permission checks using the scope-derived subject and then preload resources accordingly. The Permit.Phoenix docs describe the default behaviour as checking permissions using the current user coming from the Phoenix scope (for example, @current_scope.user), and if you are using permit_ecto, it can build filtered queries that both scope and preload records based on permission conditions. That means “scope context” (Phoenix) and “authorisation decision + query shaping” (Permit) work together: Phoenix provides the structured context, while Permit enforces policy and loads only what the subject is allowed to access.

Phoenix Scopes and Permit.Phoenix complement each other: scopes carry context, Permit enforces access rules. Phoenix Scopes give you a consistent, framework-native way to carry request context (current_scope) into your app. Permit.Phoenix then builds on that foundation by turning that scope into a concrete authorisation subject and by standardising how authorisation and resource loading happen across HTTP controllers and LiveView lifecycle points (mount, navigation, events).

Why use Permit.Phoenix instead of only raw scopes?

Phoenix Scopes give you a clean, consistent way to carry context (current_scope) into your contexts and queries, but they don’t automatically standardise how authorisation is enforced across controllers and LiveViews. Permit.Phoenix complements Scopes by turning that context into a repeatable authorisation flow: it derives the action, resource module, and subject and can automatically (a) check permissions, (b) preload a single record or a filtered list, and (c) handle unauthorised/not-found outcomes consistently.

A practical advantage is reduced duplication. In “raw scopes only” apps, you typically repeat the same pattern in many places: load the record, ensure it belongs to the current tenant/user, check role permissions, and decide what to do on failure. Permit.Phoenix centralises that into one integration layer for controllers and one for LiveView—so your contexts can stay focused on business logic while authorisation stays consistent.

Another advantage is LiveView ergonomics. LiveViews have multiple authorisation touchpoints (mount, navigation, events). Permit.Phoenix explicitly supports authorising during mount/navigation/events, while still leveraging Phoenix Scopes to determine the subject. That makes it easier to avoid “it’s authorised on mount but not on an event” drift that can creep into bigger apps.

Choosing the Right Layer

Use Phoenix Scopes to make data boundaries explicit and hard to forget: every place that loads or mutates records should have enough context to keep queries scoped correctly. Then add Permit.Phoenix once your app needs authorization that is repeatable across surfaces (controllers + LiveView) or more complex than ownership (roles, workflow states, multi-tenant rules, or multiple resource types).

A quick checklist helps decide:

  • If your rules are mostly “owner can CRUD their own records”, scopes and scoped queries may be enough.
  • If you keep rewriting “load record → verify access → handle forbidden” in multiple controllers/LiveViews, a dedicated authorization layer will pay off.
  • If LiveView events can change data, you want authorization to be enforced consistently at mount and during events—centralizing it becomes much easier than maintaining many bespoke checks.

The goal is not to pick one tool over the other. The goal is to separate responsibilities cleanly: scopes carry context; Permit enforces rules. When you do that, the codebase stays easier to extend, safer to change, and more predictable for the whole team.

Want to power your product with Elixir? We’ve got you covered.

Related posts

Dive deeper into this topic with these related posts

No items found.

You might also like

Discover more content from this category

Elixir Trickery: Cheating on Structs, And Why It Pays Off

While we can't say cheating on anyone is okay, we're not as absolutistic when it comes to cheating on Elixir at times.

3 Secrets to Unlocking Elite Developer Productivity - Joshua Plicque - Elixir Meetup #10

Introduction

Drawing from Joshua extensive experience in Elixir and Phoenix LiveView, Joshua offers valuable insights and practical techniques to help developers improve their coding efficiency and maintain high standards.

Interacting with Google Sheets

No application can function without data, and sometimes your data lives in a spreadsheet. Let's see what can we do to change this sorry state of affairs and how can we move it into an actual database.