Tutorial: Auth-gated pages and per-handler permissions
Wire login + role-based authorization into a LiveView with three primitives: LoginRequiredMixin for the page itself, get_object() + has_object_permission() for team membership, and @permission_required on each event handler. Plus the right way to handle 'session expired mid-WebSocket' so the user doesn't see a half-broken UI.
On this page
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 —
LoginRequiredMixinplus djust's object-level hooks (get_object()+has_object_permission()). Checked on the initial HTTP render, on the WebSocket mount, and again before every event. - 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 permissions changed" message if the user's permissions change mid-session, with a Reload link that preserves their place.
| You'll learn | Documented in |
|---|---|
LoginRequiredMixin + get_object() / has_object_permission() | Authorization |
@permission_required per handler | API: Decorators |
| Mid-session permission revocation handling | This tutorial |
Prerequisites: Your First LiveView, Django auth already wired in your project (a
Usermodel,/login/URL, thedjango.contrib.auth.middleware.AuthenticationMiddlewareinMIDDLEWARE). No login pages yet? djust ships themed sign-in, sign-up and password-reset pages; see Accounts. 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")]
Two layers decide who may remove a member:
TeamMembership.rolesays who is an admin of this team. This is the check that matters: it is per team.- The
manage_team_membersDjango permission is a site-wide switch: "this account may manage team rosters at all". Grant it to team admins (via a signal, the admin site, or your team-creation flow), and revoke it to lock an account out of every roster at once.
A Django permission alone is never enough here. It is global, so a user who holds it because they admin team A would pass it on team B, where they are only a member. Step 3 checks both.
Step 2 — The view: page-level auth
# myapp/views.py
from djust import LiveView, state
from djust.auth import LoginRequiredMixin
from djust.decorators import event_handler, permission_required
from .models import Team, TeamMembership
class TeamAdminView(LoginRequiredMixin, LiveView):
template_name = "team_admin.html"
login_url = "/login/" # Where to send anonymous users
error = state("")
def mount(self, request, team_id: int = 0, **kwargs):
self.team_id = team_id
def get_object(self):
return Team.objects.get(pk=self.team_id)
def has_object_permission(self, request, obj):
# Logged in is not enough: the user must belong to this team.
return obj.memberships.filter(user=request.user).exists()
def _is_team_admin(self):
# Per-team role, read fresh from the database on every call.
team = self._object
return team is not None and team.memberships.filter(
user=self.request.user, role="admin"
).exists()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team = self._object # fetched and verified for this render
if team is not None:
context["is_team_admin"] = self._is_team_admin()
context["team_name"] = team.name
context["invite_code"] = team.invite_code
context["members"] = [
{"id": m.id, "username": m.user.username, "role": m.role}
for m in team.memberships.select_related("user").order_by("user__username")
]
return context
Two complementary checks, both run by the framework:
| Check | Who handles it | What happens on failure |
|---|---|---|
request.user.is_authenticated | LoginRequiredMixin (djust.auth) | HTTP 302 → /login/?next=/teams/<id>/ |
has_object_permission() | Our membership rule | HTTP 403 on the page load; the WebSocket mount is refused with close code 4403 |
LoginRequiredMixin from djust.auth sets login_required = True, which
djust checks before mount() on the HTTP render and again on the WebSocket
mount. (Django's own django.contrib.auth.mixins.LoginRequiredMixin is
honoured on both paths too.) get_object() and has_object_permission() run
after mount(), because get_object() reads the team_id that mount()
stored, and they run again before every event, so a user removed from the
team loses access on their next click. A missing team (Team.DoesNotExist)
is treated as "no object" rather than a 403, so the response doesn't reveal
which team ids exist; self._object is then None, which is why
get_context_data() checks it. See
Authorization for the full four-layer model.
Step 3 — The view: action-level auth on the handler
class TeamAdminView(LoginRequiredMixin, LiveView):
# ... mount(), get_object() and friends as above ...
@event_handler
@permission_required("myapp.manage_team_members")
def remove_member(self, member_id: int = 0, **kwargs):
# The global permission above is not per team. This is the check
# that stops an admin of team A from editing team B's roster.
if not self._is_team_admin():
self.error = "Only this team's admins can remove members."
return
if not member_id:
return
try:
membership = self._object.memberships.get(id=member_id)
except TeamMembership.DoesNotExist:
self.error = "That member is no longer on the team."
return
# Don't let an admin remove themselves into a teamless state.
if membership.user_id == self.request.user.id:
self.error = "You can't remove yourself from the team."
return
membership.delete()
self.error = ""
Four things to call out:
@permission_required("myapp.manage_team_members")is checked server-side BEFORE the handler runs. A user without the permission who fires this event from the JS console gets a "Permission denied" error from the framework —remove_memberis never invoked, no DB changes happen._is_team_admin()scopes it to this team. The permission is global, so on its own it would let an admin of any team remove members of every team they belong to. The role check runs first in the handler, againstself._object(the teamhas_object_permission()verified for this event), and reads the role from the database each time, so a demotion takes effect on the next click.- Decorator order doesn't change behaviour.
@permission_requiredonly records metadata that the dispatcher reads, so either order works. By convention@event_handlergoes outside and@permission_requiredinside (closer todef). - Self-protection (
membership.user_id == self.request.user.id) is business logic, not auth — it stays inside the handler. The framework's auth layer doesn't know about your team rules.
The member list re-renders from get_context_data() after the handler,
so the removed row disappears without any manual refresh.
Name collision. If the view also sets the class attribute
permission_required = "..."(the view-level check), that string shadows the decorator inside the class body. Import the decorator under another name in that case:from djust.decorators import permission_required as require_permission.
Step 4 — The template: hide the button for non-admins
<!-- myapp/templates/team_admin.html -->
<section class="team-admin" dj-root>
<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>
{% for member in members %}
<tr>
<td>{{ member.username }}</td>
<td>{{ member.role|capfirst }}</td>
<td>
{% if is_team_admin %}
<button
type="button"
dj-click="remove_member"
data-member-id:int="{{ member.id }}"
class="btn-danger"
>Remove</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
is_team_admin comes from get_context_data(), so the button follows
the per-team role, not the global perms.myapp.manage_team_members (which
would show the button on every team the user belongs to). It hides the
button visually for non-admins, but it does NOT enforce auth.
The role check and @permission_required in the handler are what
actually block 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 server-side checks for actual security. Without them, 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.
A demotion to member on this team is caught by _is_team_admin():
the handler sets error, and the re-render shows the message and drops
the button. Losing the site-wide manage_team_members permission is
different. The handler never runs, so there is no server-side hook to
catch it in: the framework rejects the event before dispatch and sends a
{"type": "error", "error": "Permission denied"} frame back. The
client turns every error frame into a djust:error window event,
so listen for that and show a banner:
<!-- in your base template, after the dj-root element -->
<div id="perm-revoked" class="banner banner-warn" role="alert" hidden>
Your permissions on this team have changed.
<a href="{{ request.path }}">Reload</a> to see the updated view.
</div>
<script>
window.addEventListener("djust:error", (e) => {
if (e.detail && e.detail.error === "Permission denied") {
document.getElementById("perm-revoked").hidden = false;
}
});
</script>
Keep the banner and the script outside the dj-root element: the
reactive root is re-rendered from the server, which would reset the
hidden attribute, and a <script> inside it is not re-executed
after a morph.
Now when the demoted admin clicks Remove, instead of a silent
fail, 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.
A member removed from the team entirely is handled by Step 2 instead:
has_object_permission() re-runs before their next event and denies
it with a permission_denied error frame.
What just happened, end to end
Anonymous visitor Member (non-admin) Admin
│ │ │
│ GET /teams/42/ │ │
│ ─────────────► │ │
│ ◄ 302 /login/?next=... │ │
│ (LoginRequiredMixin) │ │
│ │ │
│ │ GET /teams/42/ │
│ │ ─────────────► │
│ │ membership check passes │
│ │ ◄ render page │
│ │ (no Remove buttons, │
│ │ template-hidden) │
│ │ │
│ │ │ GET /teams/42/
│ │ │ ─────────────►
│ │ │ ◄ render page
│ │ │ (Remove buttons
│ │ │ visible)
│ │ │
│ │ │ click Remove
│ │ │ ─────────────►
│ │ │ @permission_required ✓
│ │ │ role on team 42 = admin ✓
│ │ │ membership.delete()
│ │ │ ◄ patch: row gone
│ │ │
│ │ console: dispatch │
│ │ ('remove_member', {id}) │
│ │ ─────────────► │
│ │ role on team 42 ✗ │
│ │ ◄ "Only this team's │
│ │ admins can remove..." │
│ │ (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
- Rate-limit destructive actions: stack
@rate_limit(rate=0.1, burst=5)(a burst of 5, then one every 10 seconds; see the 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 recipe (LoginRequiredMixin plus has_object_permission() for
page-level, a per-object role check plus @permission_required for
action-level, a template {% if %} 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.