---
title: "Tutorial: Auth-gated pages and per-handler permissions"
slug: tutorial-authentication
section: guides
order: 69
level: intermediate
description: "Wire login + role-based authorization into a LiveView with three primitives: LoginRequiredMixin for the page itself, @permission_required on each event handler, and self.redirect() for graceful denial. Plus the right way to handle 'session expired mid-WebSocket' so the user doesn't see a half-broken UI."
---

# 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 learn | Documented in |
|---|---|
| `LoginRequiredMixin` + `self.redirect()` | [Authorization](/guides/authorization/) |
| `@permission_required` per handler | [API: Decorators](/api/decorators/) |
| Mid-session permission revocation handling | This tutorial |
| `request.user.is_authenticated` early-return in `mount()` | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), 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](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions-and-authorization))
> helps but isn't required.

---

## Step 1 — The model + permissions

```python
# 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()

```python
# 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()`:

| Check | Who handles it | What happens on failure |
|---|---|---|
| `request.user.is_authenticated` | `LoginRequiredMixin` (Django standard) | HTTP 302 → `/login/?next=/teams/<id>/` |
| `TeamMembership.objects.get(...)` | Our explicit lookup | `self.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

```python
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

```html
<!-- 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:

```python
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:

```html
{% 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:
  ```python
  @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](/guides/tutorial-optimistic-updates/)) 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](/guides/tutorial-multi-step-wizard/)
  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.
