Skip to content

HTMX Authentication & Authorization Pattern

The Problem

HTMX follows HTTP 302 redirects transparently. When an HTMX partial request hits an auth decorator (e.g., @login_required) that returns a redirect, HTMX follows it and swaps the entire redirected page (login page, chat page, setup wizard) into the small partial target element. This causes broken UI — for example, the full sidebar rendering inside a dropdown.

This affects any view that can return a redirect when called via HTMX: - Django's @login_required → redirects to LOGIN_URL - Django's @staff_member_required → redirects to the admin login (never use it; see Rules) - Our @registered_user_required → redirects anonymous users to chat - SetupRequiredMiddleware → redirects to /setup/ when workspace is unconfigured

The Solution: HX-Redirect Header

Instead of returning a bare 302, HTMX-aware decorators return a 200 response with an HX-Redirect header. HTMX handles this by performing a full-page client-side navigation — the redirect works correctly without swapping a full page into a partial target.

All auth decorators live in campus_core/decorators.py.

Available Decorators

@htmx_login_required

Drop-in replacement for Django's @login_required. Use on any HTMX endpoint that requires an authenticated user.

from campus_core.decorators import htmx_login_required

@htmx_login_required
def load_conversations(request):
    ...

@registered_user_required

Requires an authenticated user. Already HTMX-aware.

from campus_core.decorators import registered_user_required

@registered_user_required
def folder_selector(request):
    ...

@htmx_superuser_required

The operator gate: requires is_superuser and guards every /cc_admin/ tool and the setup wizard. Authenticated non-superusers get a 403 (non-HTMX) or HX-Redirect: / (HTMX), and every denial writes a permission_denied audit event with reason superuser_required. There is no staff-tier decorator - is_staff grants nothing (see the access model in security-and-compliance.md).

from campus_core.decorators import htmx_superuser_required

@htmx_superuser_required
def admin_conversation_viewer(request):
    ...

@roles_required / @feature_required

Role-gated and feature-gated views, both HTMX-aware and denial-audited; see campus_core/decorators.py.

_htmx_aware_redirect(request, url)

For redirects inside view logic (not decorators). Checks the HX-Request header and returns the appropriate response type.

from campus_core.decorators import _htmx_aware_redirect

def my_view(request):
    if some_condition:
        return _htmx_aware_redirect(request, "/somewhere/")
    ...

Middleware

SetupRequiredMiddleware (campus_core/middleware.py) is also HTMX-aware. When the workspace is unconfigured and an HTMX request arrives, it returns HX-Redirect instead of a 302 or 503 page.

Rules

  1. Never use Django's @login_required on HTMX endpoints — use @htmx_login_required
  2. Never use Django's @staff_member_required anywhere — admin surfaces are operator-gated with @htmx_superuser_required
  3. Never use bare redirect() in HTMX-serving views — use _htmx_aware_redirect(request, url)
  4. Hide UI elements that require auth for users who can't access them (e.g., the "+" knowledge folder button is hidden for anonymous users in chat_input_inner.html)

How It Works

Normal request:    decorator returns 302 → browser follows redirect → full page loads (correct)
HTMX request:     decorator returns 200 + HX-Redirect header → HTMX navigates full page (correct)
HTMX + bare 302:  HTMX follows redirect → swaps full page into partial target (BROKEN)

Testing

Tests are in apps/main_app/tests/test_htmx_auth.py. Each decorator is tested for: - Normal request + unauthenticated → returns 302 - HTMX request + unauthenticated → returns 200 with HX-Redirect - Authenticated user → passes through to view (200 with content)

The middleware tests verify the same pattern for the setup-required flow.

The SPA builds UI with React components, not HTML strings - this rule is about the remaining Django-rendered HTMX surfaces (/cc_admin/, setup). There, avoid inline HTML strings in vanilla JS; fetch a server-rendered Django partial via HTMX instead.

// BAD - inline HTML in JS
errorDiv.innerHTML = `<div class="p-3 bg-red-100"><p>Error occurred</p></div>`;

// GOOD - fetch a server-rendered admin partial via HTMX
htmx.ajax('GET', `/cc_admin/.../error/?message=${encodeURIComponent(msg)}`,
          { target: '#target', swap: 'beforeend' });

Trivial JS DOM updates (text, class/attribute changes, element removal) are fine; for anything with HTML structure, render a Django partial. In the SPA, this concern is moot - untrusted HTML is sanitized through lib/render/sanitize.ts (DOMPurify) before the single dangerouslySetInnerHTML.