djust docs
Browse documentation
intermediate

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)

AttributeTypeDefaultDescription
login_requiredboolNoneTrue = must be authenticated
permission_requiredstr | list[str]NoneDjango permission string(s)
login_urlstrNoneOverride 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, PermissionRequiredMixin and UserPassesTestMixin (including your own test_func()), and a subclass of one of them whose dispatch() only does setup;
  • Django's login_required, staff_member_required, user_passes_test(...) and permission_required(...) applied with @method_decorator to dispatch, get or post;
  • the same decorators wrapped around as_view() in the URLconf (path("x/", login_required(MyView.as_view()))) or by an as_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's otp_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 AccessMixin subclass with its own dispatch(). Declare permission_required or check_permissions(); login_required = True alone does not cover it;
  • a setup(), dispatch(), get() or post() override (or a subclass of Django's mixins whose dispatch() 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), calls handle_no_permission() or redirect_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 with get_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

  1. Always set login_required — even login_required = False is better than leaving it as None, because it shows intent
  2. Use permission_required for role-based access — maps directly to Django's permission system
  3. Use check_permissions() for object-level access — verify the user can access this specific resource
  4. Use @permission_required on destructive handlers — even if the view is already protected, adding handler-level permission for delete/update operations provides defense-in-depth
  5. Run djust_audit in 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_required for 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)