Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

5 min read

Tutorial: Auth-gated pages and per-handler permissions

Every shipped LiveView eventually answers two questions: "can this user see this page?" and "can this user perform this action?" The two are different — a viewer might be allowed to see a team admin page but only an admin can press the "Remove member" button on it. Mixing them up leads to either a too-restrictive UI (forcing admins to log in twice) or a too-permissive one (every viewer can fire the admin event over the WebSocket).

djust separates them cleanly:

  • Page-level auth — Django's standard LoginRequiredMixin plus djust's self.redirect() helper. Decided once, on mount.
  • Action-level auth@permission_required("…") on each event handler. Decided server-side before the handler runs. The user's role can change mid-session and the next event is re-checked.

By the end of this tutorial you'll have:

  • A team admin page that redirects anonymous visitors to /login/ before they see anything.
  • The page renders for any logged-in member of the team — they can view the roster, see invite codes, etc.
  • A "Remove member" button that's only callable by admins — even if a non-admin somehow fires the event from the console, the server rejects it.
  • A graceful "Your session expired" message if the user's permissions change mid-session, with a Re-login link that preserves their place.
You'll learnDocumented in
LoginRequiredMixin + self.redirect()Authentication (legacy)
@permission_required per handlerAPI: Decorators
Mid-session permission revocation handlingThis tutorial
request.user.is_authenticated early-return in mount()This tutorial

Prerequisites: Quickstart, Django auth already wired in your project (a User model, /login/ URL, the django.contrib.auth.middleware.AuthenticationMiddleware in MIDDLEWARE). Familiarity with Django permissions (docs) helps but isn't required.


Step 1 — The model + permissions

# myapp/models.py
from django.conf import settings
from django.db import models


class Team(models.Model):
    name = models.CharField(max_length=80)
    invite_code = models.CharField(max_length=32, unique=True)

    class Meta:
        permissions = [
            ("manage_team_members", "Can add or remove team members"),
        ]


class TeamMembership(models.Model):
    team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="memberships")
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    role = models.CharField(max_length=20, choices=[("member", "Member"), ("admin", "Admin")])

    class Meta:
        unique_together = [("team", "user")]

The manage_team_members Django permission is what we'll gate the destructive button on. Assign it to admins via a Django signal, the admin site, or your team-creation flow.


Step 2 — The view: page-level auth in mount()

# myapp/views.py
from django.contrib.auth.mixins import LoginRequiredMixin

from djust import LiveView, state, event_handler
from djust.decorators import permission_required

from .models import Team, TeamMembership


class TeamAdminView(LoginRequiredMixin, LiveView):
    template_name = "team_admin.html"
    login_url = "/login/"  # Where to send anonymous users

    members = state(default_factory=list)
    team_name = state("")
    invite_code = state("")
    error = state("")

    def mount(self, request, *, team_id: int, **kwargs):
        # LoginRequiredMixin handles anonymous → redirect-to-login
        # for the initial HTTP GET. We still need to handle the
        # "user is authenticated but not a member of this team"
        # case ourselves.
        try:
            self._membership = TeamMembership.objects.select_related("team").get(
                team_id=team_id, user=request.user,
            )
        except TeamMembership.DoesNotExist:
            self.redirect("/teams/")  # send them somewhere safe
            return

        self.team = self._membership.team
        self.team_name = self.team.name
        self.invite_code = self.team.invite_code
        self._refresh_members()

    def _refresh_members(self):
        self.members = [
            {
                "id": m.id,
                "user_id": m.user_id,
                "username": m.user.username,
                "role": m.role,
            }
            for m in self.team.memberships.select_related("user").order_by("user__username")
        ]

Two complementary checks in mount():

CheckWho handles itWhat happens on failure
request.user.is_authenticatedLoginRequiredMixin (Django standard)HTTP 302 → /login/?next=/teams/<id>/
TeamMembership.objects.get(...)Our explicit lookupself.redirect("/teams/") — graceful re-route, no error page

We use self.redirect() (not raise PermissionDenied) because a LiveView mount runs in WebSocket context too — the upgrade has already happened, so re-rendering an error page is awkward. self.redirect() cleanly tells the framework "send the client to this URL instead."


Step 3 — The view: action-level auth on the handler

class TeamAdminView(LoginRequiredMixin, LiveView):
    # ... mount() and state as above ...

    @event_handler
    @permission_required("myapp.manage_team_members")
    def remove_member(self, member_id: int = 0, **kwargs):
        if not member_id:
            return
        # Don't let an admin demote themselves into a teamless state.
        if member_id == self._membership.id:
            self.error = "You can't remove yourself from the team."
            return
        try:
            membership = self.team.memberships.get(id=member_id)
        except TeamMembership.DoesNotExist:
            self.error = "That member is no longer on the team."
            self._refresh_members()
            return
        membership.delete()
        self.error = ""
        self._refresh_members()

Three things to call out:

  1. @permission_required("myapp.manage_team_members") is checked server-side BEFORE the handler runs. A non-admin who fires this event from the JS console gets a "Permission denied" error from the framework — remove_member is never invoked, no DB changes happen.
  2. Decorator order matters. Both forms work, but put @event_handler outside (closer to the class) and @permission_required inside (closer to def). Reversed, the framework can't see the permission requirement.
  3. Self-protection (if member_id == self._membership.id) is business logic, not auth — it stays inside the handler. The framework's auth layer doesn't know about your team rules.

Step 4 — The template: hide the button for non-admins

<!-- myapp/templates/team_admin.html -->
<section class="team-admin">
  <header>
    <h1>{{ team_name }}</h1>
    <p>Invite code: <code>{{ invite_code }}</code></p>
  </header>

  {% if error %}
    <p role="alert" class="err">{{ error }}</p>
  {% endif %}

  <table class="members">
    <thead>
      <tr>
        <th>Member</th>
        <th>Role</th>
        <th aria-hidden="true"></th>
      </tr>
    </thead>
    <tbody>
      {% dj-for member in members %}
        <tr>
          <td>{{ member.username }}</td>
          <td>{{ member.role|capfirst }}</td>
          <td>
            {% if perms.myapp.manage_team_members %}
              <button
                type="button"
                dj-click="remove_member"
                data-member-id="{{ member.id }}"
                class="btn-danger"
              >Remove</button>
            {% endif %}
          </td>
        </tr>
      {% end-dj-for %}
    </tbody>
  </table>
</section>

The {% if perms.myapp.manage_team_members %} is a Django template feature — perms is auto-injected by django.contrib.auth.context_processors.auth. It hides the button visually for non-admins, but it does NOT enforce auth. The @permission_required decorator on the handler is what actually blocks the action; the template guard is a UX nicety (don't taunt the user with a button that wouldn't work).

Defence in depth. Always have BOTH: the template guard for UX, the decorator for actual security. Without the decorator, any non-admin can trigger the event by typing a few lines into their console — the template hide is browser-controlled.


Step 5 — Mid-session permission revocation

The hard case: a user loaded the page as an admin, then was demoted mid-session by another admin. The "Remove" button is still visible in their DOM (the page hasn't re-rendered) but the server now rejects their remove_member events.

Out of the box, the framework sends a "Permission denied" error event back to the client. Catch it and turn it into a useful UI:

class TeamAdminView(LoginRequiredMixin, LiveView):
    # ... as before ...

    revoked = state(False)

    def handle_error(self, exc):
        """Called when an event handler raises (or auth rejects).

        Default behavior is to re-raise. We override here to
        handle the specific 'permission revoked mid-session' case
        gracefully.
        """
        if isinstance(exc, PermissionDenied):
            self.revoked = True
            return  # swallow — we've handled it
        raise exc

In the template:

{% if revoked %}
  <div class="banner banner-warn" role="alert">
    Your permissions on this team have changed.
    <a href="{{ request.path }}">Reload</a> to see the updated view.
  </div>
{% endif %}

Now when the demoted admin clicks Remove, instead of a silent fail or a stack trace, they see a clear banner explaining what happened. Reloading the page re-runs mount(), the up-to-date member list renders, and the (now hidden) Remove button isn't shown.


What just happened, end to end

   Anonymous visitor                      Member (non-admin)              Admin
        │                                       │                            │
        │ GET /teams/42/                        │                            │
        │ ─────────────►                        │                            │
        │ ◄ 302 /login/?next=...                │                            │
        │ (LoginRequiredMixin)                  │                            │
        │                                       │                            │
        │                                       │ GET /teams/42/             │
        │                                       │ ─────────────►             │
        │                                       │ mount() succeeds           │
        │                                       │ ◄ render page              │
        │                                       │   (no Remove buttons,      │
        │                                       │    template-hidden)        │
        │                                       │                            │
        │                                       │                            │ GET /teams/42/
        │                                       │                            │ ─────────────►
        │                                       │                            │ ◄ render page
        │                                       │                            │   (Remove buttons
        │                                       │                            │    visible)
        │                                       │                            │
        │                                       │                            │ click Remove
        │                                       │                            │ ─────────────►
        │                                       │                            │ @permission_required ✓
        │                                       │                            │ membership.delete()
        │                                       │                            │ ◄ patch: row gone
        │                                       │                            │
        │                                       │ console: dispatch          │
        │                                       │ ('remove_member', {id})    │
        │                                       │ ─────────────►             │
        │                                       │ @permission_required ✗     │
        │                                       │ ◄ "Permission denied"      │
        │                                       │   (no DB change)           │

The non-admin can fire the event from the console, but the server refuses to run the handler. Defense at the layer that matters.


Where to go next

  • Object-level permissions: @permission_required checks Django's permission strings, which are usually granted globally. For "this admin can manage this team but not that one," check the relationship inside the handler:
    @event_handler
    def remove_member(self, **kwargs):
        if not self._membership.role == "admin":
            raise PermissionDenied
        # ... rest of handler ...
    
  • Rate-limit destructive actions: stack @rate_limit(max_calls=5, per_seconds=60) (see optimistic- updates tutorial) so a compromised admin account can't bulk-delete members in a script.
  • Audit log: wrap remove_member in a with audit_log(...): context manager that writes to a separate audit table — the who/what/when of each destructive action, separate from the business data.
  • Two-factor for high-stakes actions: for true destructive ops (delete team, transfer ownership), require a fresh password re-confirm in a modal before firing the event. Pair with the multi-step wizard tutorial pattern.

The three-primitive recipe (LoginRequiredMixin for page-level, @permission_required for action-level, template {% if perms.X %} for UX-only hide) is the entire auth surface for ~95% of LiveViews. Everything beyond that is business-logic checks inside the handler body — and those are just regular Python.

Spotted a typo or want to improve this page? Edit on GitHub →