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
LoginRequiredMixinplus djust'sself.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() | Authentication (legacy) |
@permission_required per handler | API: Decorators |
| Mid-session permission revocation handling | This tutorial |
request.user.is_authenticated early-return in mount() | This tutorial |
Prerequisites: Quickstart, Django auth already wired in your project (a
Usermodel,/login/URL, thedjango.contrib.auth.middleware.AuthenticationMiddlewareinMIDDLEWARE). 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():
| 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
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:
@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_memberis never invoked, no DB changes happen.- Decorator order matters. Both forms work, but put
@event_handleroutside (closer to the class) and@permission_requiredinside (closer todef). Reversed, the framework can't see the permission requirement. - 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_requiredchecks 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_memberin awith 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.