Authentication & Authorization
Requiring login, checking permissions, and auditing what each view requires.
On this page
djust provides opinionated, framework-enforced authentication and authorization for LiveViews. Auth checks run server-side before mount() and before individual event handlers — no client-side bypass is possible.
Quick Start
from djust import LiveView
class DashboardView(LiveView):
template_name = "dashboard.html"
login_required = True
def mount(self, request, **kwargs):
self.metrics = get_metrics(request.user)
That's it. Unauthenticated users are automatically redirected to settings.LOGIN_URL.
View-Level Auth
Class Attributes (Primary API)
| Attribute | Type | Default | Description |
|---|---|---|---|
login_required | bool | None | True = must be authenticated |
permission_required | str | list[str] | None | Django permission string(s) |
login_url | str | None | Override settings.LOGIN_URL |
class AdminView(LiveView):
template_name = "admin/dashboard.html"
login_required = True
permission_required = "analytics.view_dashboard"
login_url = "/admin/login/"
def mount(self, request, **kwargs):
self.stats = get_admin_stats()
Multiple permissions (all required):
class BillingView(LiveView):
login_required = True
permission_required = ["billing.view_invoices", "billing.manage_payments"]
Mixins (Django-Familiar Convenience)
If you prefer Django's mixin pattern:
from djust.auth import LoginRequiredMixin, PermissionRequiredMixin
class DashboardView(LoginRequiredMixin, PermissionRequiredMixin, LiveView):
template_name = "dashboard.html"
permission_required = "analytics.view_dashboard"
LoginRequiredMixin sets login_required = True.
PermissionRequiredMixin sets login_required = True and expects you to set permission_required.
Custom Authorization Logic
Override check_permissions() for authorization logic that goes beyond Django's permission system:
class ProjectView(LiveView):
login_required = True
def check_permissions(self, request):
"""Return True to allow, raise PermissionDenied or return False to deny."""
project = Project.objects.get(pk=self.kwargs.get("pk"))
return project.team.members.filter(user=request.user).exists()
The method is only called if the subclass defines it — the base LiveView class does not invoke it by default.
Django Auth Mixins, Custom Access Mixins and Method Decorators
The live transports (the WebSocket and SSE mount, events, the HTTP POST event
fallback, the API and {% live_render %} children) never call the view's
setup(), dispatch(), get() or post(), and never run the wrappers the
URLconf puts around as_view(). djust handles what lives there as follows.
Replayed on every live transport. The same users are admitted as on the
HTTP request. An authenticated user who fails the test gets PermissionDenied
on live transports, where Django's HTTP response would redirect to the login
page; an anonymous user gets the login redirect.
- Django's
LoginRequiredMixin,PermissionRequiredMixinandUserPassesTestMixin(including your owntest_func()), and a subclass of one of them whosedispatch()only does setup; - Django's
login_required,staff_member_required,user_passes_test(...)andpermission_required(...)applied with@method_decoratortodispatch,getorpost; - the same decorators wrapped around
as_view()in the URLconf (path("x/", login_required(MyView.as_view()))) or by anas_view()override. djust reads them from the server's URLconf, never from the URL a client sends; a view routed more than once gets every route's wrappers; - another package's decorator built on
user_passes_test(django-otp'sotp_required, for example); - an async test function, which is awaited.
A decorator applied only to post runs for events (and the HTTP POST event
fallback), not for the mount, as Django runs it for a POST and not for the
GET that renders the page. It applies to every event on the page: the view's
own, its components', its embedded views' and its uploads, as on HTTP, where
all of them go through the page view's post(). @method_decorator(login_not_required, name="dispatch") (a public view under LoginRequiredMiddleware) is not
access control and changes nothing.
Refused unless the view declares a rule that covers it, because no live transport can run it:
- a custom
AccessMixinsubclass with its owndispatch(). Declarepermission_requiredorcheck_permissions();login_required = Truealone does not cover it; - a
setup(),dispatch(),get()orpost()override (or a subclass of Django's mixins whosedispatch()checks the user) that checks the user (any attribute of it:is_staff,groups,role,getattr(user, ...)) and denies by any means: it raises (PermissionDenied,Http404, anything else), callshandle_no_permission()orredirect_to_login(), returns a redirect (redirect(...),HttpResponseRedirect), an error response or a page of its own (render(...),TemplateResponse,HttpResponse(...),self.http_method_not_allowed(...)), or looks an object up withget_object_or_404()/get_list_or_404(); these names also count when imported under another name (from django.shortcuts import redirect as go); - a decorator applied to one of those methods, or a URLconf wrapper, whose
code checks the user (also when another decorator is stacked above
@method_decorator(...), or when the decorator returns another decorator's wrapper).
Any other decorator or URLconf wrapper djust does not recognise as Django's or
its own (a rate limiter, a CSP decorator, a project logging decorator) is not
refused on this release line when its code does not check the user. It does
not run on live transports either, and djust.V013 warns about it.
A declared login_required = True (or LoginRequiredLiveViewMixin) covers a
gate that reads only is_authenticated / is_anonymous; a gate that reads
any other attribute of the user (staff, groups, a role, permissions) needs
permission_required or check_permissions():
class StaffOnlyView(StaffRequiredMixin, LiveView):
login_required = True
def check_permissions(self, request):
return request.user.is_staff
System check djust.S012 (an Error) names every view defined in the project
that the runtime refuses. djust.V013 (a warning) names the rest: an override
that reads the user (is_authenticated, is_staff, has_perm) without
denying, and an unknown decorator or URLconf wrapper, which are not refused
but are not run on live transports either, and a refused view that an
installed package defines. Because djust.S012 is an Error, manage.py check
and migrate stop until a refused view declares its rule.
login_required = False does not count as a declaration. Detection is a
heuristic over the view's code: a check inside a helper function (also when
the view passes it the user or the request), a check that does not read the
user (a session flag, the client address), a dispatch built with
functools.partialmethod or a custom descriptor, and a URLconf wrapper that
does not use functools.wraps are not recognised (djust.V013 still warns
about every setup()/dispatch()/get()/post() override, and names a
route whose wrapper it cannot read). Live transports use ROOT_URLCONF; a
URLconf that middleware selects per request (django-hosts) applies only to the
SSE and HTTP POST paths. Django's LoginRequiredMiddleware is not applied on
live transports: declare login_required = True on the views it protects.
An async test function is awaited from the worker thread live transports
call the check from; called directly on the event loop's thread, it refuses
the request (call check_view_auth() through sync_to_async). Declared
djust auth is the contract every transport enforces.
Explicitly Public Views
Set login_required = False to explicitly mark a view as public. This suppresses the djust_audit warning:
class LandingView(LiveView):
login_required = False # Explicitly public
def mount(self, request, **kwargs):
self.features = get_public_features()
Handler-Level Permissions
Protect individual event handlers with @permission_required:
from djust.decorators import event_handler, permission_required
class ProjectView(LiveView):
login_required = True
@event_handler()
def search(self, query: str = "", **kwargs):
# Any authenticated user can search
self.results = Project.objects.filter(name__icontains=query)
@permission_required("projects.delete_project")
@event_handler()
def delete_project(self, project_id: int, **kwargs):
# Only users with delete permission
Project.objects.get(pk=project_id).delete()
When permission is denied, the client receives a "Permission denied" error message. The handler never executes.
How It Works
Mount Flow
Client sends "mount" message
→ WebSocket consumer creates view instance
→ check_view_auth() runs:
1. login_required? → check request.user.is_authenticated
2. permission_required? → check request.user.has_perms()
3. check_permissions()? → call custom hook (if overridden)
→ If unauthenticated: send {"type": "navigate", "to": "/login/"}
→ If authenticated but lacking permission_required perms: close with 4403 (PermissionDenied)
→ If check_permissions() returns False or raises PermissionDenied:
navigate to login_url (not a 403), even for a logged-in user
→ If passed: call view.mount(request, **kwargs)
Event Flow
Client sends "event" message
→ _validate_event_security() runs:
1. Event name safety check
2. Handler exists and is @event_handler
3. Rate limit check
4. @permission_required check (if present)
→ If denied: send {"type": "error", "error": "Permission denied"}
→ If passed: call handler(**params)
System Checks
djust registers a Django system check (djust.S005) that warns when LiveView subclasses expose state without authentication:
$ python manage.py check
System check identified some issues:
WARNINGS:
?: (djust.S005) myapp.views.ContactListView exposes state without authentication.
HINT: Add login_required = True or permission_required to protect this view,
or set login_required = False to acknowledge public access.
This check runs automatically in CI via python manage.py check.
Audit Integration
The djust_audit command shows auth configuration for every view:
$ python manage.py djust_audit
LiveView: crm.views.ContactListView
Template: crm/contacts/list.html
Auth: login_required, permission: crm.view_contacts
Exposed state:
contacts (mount)
search_query (mount)
Handlers:
* search(value: str = "") @debounce(wait=0.3)
* delete(contact_id: int) @permission_required(crm.delete_contact)
LiveView: public.views.LandingView
Template: public/landing.html
Auth: (none) ⚠ exposes state without auth
Exposed state:
features (mount)
JSON output includes an "unprotected_with_state" count in the summary.
Best Practices
- Always set
login_required— evenlogin_required = Falseis better than leaving it asNone, because it shows intent - Use
permission_requiredfor role-based access — maps directly to Django's permission system - Use
check_permissions()for object-level access — verify the user can access this specific resource - Use
@permission_requiredon destructive handlers — even if the view is already protected, adding handler-level permission for delete/update operations provides defense-in-depth - Run
djust_auditin CI — catch unprotected views before they ship
Known Limitations
Auth is checked at mount time only (by default)
By default, view-level auth (login_required, permission_required, check_permissions()) runs once during WebSocket mount. If a user's session expires or permissions are revoked while the WebSocket is open, subsequent events will continue to be processed until the next reconnect.
This is a conscious trade-off shared by all LiveView-style frameworks (Phoenix, Laravel Livewire, etc.) — re-checking auth on every event would add a database query per interaction.
Mitigations:
- Use handler-level
@permission_requiredfor sensitive operations (checked per event) - WebSocket heartbeats will eventually detect stale sessions
- Set
LIVEVIEW_CONFIG = {"reauth_on_event": True}to re-run view-level auth (login_required/permission_required) on every event, at the cost of one session read per event. On failure the client is sent to the login URL and the socket closes with 4403. - For object-level access, use
get_object()+has_object_permission(), which are re-checked on every event (see Authorization)