---
title: "Authentication & Permissions"
slug: authentication
section: guides
order: 17
level: intermediate
description: "Requiring login, checking permissions, and auditing what each view requires."
---

# Authentication & Authorization

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

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

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

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

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

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

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

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

```python
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](authorization.md))
