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¶
- Never use Django's
@login_requiredon HTMX endpoints — use@htmx_login_required - Never use Django's
@staff_member_requiredanywhere — admin surfaces are operator-gated with@htmx_superuser_required - Never use bare
redirect()in HTMX-serving views — use_htmx_aware_redirect(request, url) - 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.
Related convention: minimize HTML in JavaScript¶
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.