Skip to content
docs.djust.org

Error Code Reference

djust uses structured error codes to help you diagnose problems quickly. This guide covers every error code, what causes it, and how to fix it.


Error Code Categories

PrefixCategoryWhen Checked
C0xxConfigurationmanage.py check --tag djust (startup)
V0xxValidationmanage.py check --tag djust (startup)
S0xxSecuritymanage.py check --tag djust (startup)
T0xxTemplatesmanage.py check --tag djust (startup)
Q0xxCode Qualitymanage.py check --tag djust (startup)
A0xxAudit / Static Security Checksmanage.py check --tag djust (startup)
P0xxPermissions Documentmanage.py djust_audit --permissions permissions.yaml
L0xxLive Runtime Probemanage.py djust_audit --live <url>
X0xxAST Anti-Pattern Scannermanage.py djust_audit --ast
DJE-xxxRuntimeDuring WebSocket events and VDOM diffing

Run all static checks at once:

python manage.py check --tag djust

Run the runtime probe against a deployed environment:

python manage.py djust_audit --live https://staging.example.com --strict

Validate against a committed permissions document:

python manage.py djust_audit --permissions permissions.yaml --strict

Configuration Errors (C0xx)

C001: ASGI_APPLICATION not set

Severity: Error

What causes it: Your Django settings file is missing the ASGI_APPLICATION setting. djust requires ASGI for WebSocket support.

What you see: LiveView pages load as static HTML. No WebSocket connection is established.

Fix:

# settings.py
ASGI_APPLICATION = "myproject.asgi.application"

C002: CHANNEL_LAYERS not configured

Severity: Error

What causes it: Django Channels is not configured. djust uses Channels for WebSocket communication.

What you see: WebSocket connections fail. Browser console shows WebSocket connection errors.

Fix:

# settings.py (development)
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer",
    }
}

# settings.py (production -- use Redis)
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    }
}

C003: daphne ordering in INSTALLED_APPS

Severity: Warning (wrong order) / Info (missing)

What causes it: daphne is listed after django.contrib.staticfiles in INSTALLED_APPS, or is missing entirely.

What you see: manage.py runserver starts the WSGI server instead of the ASGI server, so WebSockets do not work in development.

Fix:

# settings.py
INSTALLED_APPS = [
    "daphne",                          # Must be before staticfiles
    "django.contrib.staticfiles",
    "djust",
    # ...
]

C004: djust not in INSTALLED_APPS

Severity: Error

What causes it: The djust app is not in your INSTALLED_APPS.

What you see: Template tags, static files, and system checks are not available.

Fix:

# settings.py
INSTALLED_APPS = [
    # ...
    "djust",
    # ...
]

C005: WebSocket routes missing AuthMiddlewareStack

Severity: Warning

What causes it: Your ASGI routing does not wrap the WebSocket URL router with AuthMiddlewareStack or DjustMiddlewareStack.

What you see: request.session and request.user are unavailable inside mount(). Authentication-related features silently fail.

Fix:

# asgi.py
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(   # Wrap with this
        URLRouter(websocket_urlpatterns)
    ),
})

C010: Tailwind CDN in production

Severity: Warning

What causes it: A base/layout template includes the Tailwind CSS CDN script in a non-DEBUG environment.

What you see: Slow page loads and console warnings about Tailwind CDN usage in production.

Fix: Compile Tailwind CSS instead of using the CDN:

python manage.py djust_setup_css tailwind
# Or manually:
tailwindcss -i static/css/input.css -o static/css/output.css --minify

C011: Missing compiled CSS

Severity: Warning (production) / Info (development)

What causes it: Tailwind is configured (tailwind.config.js exists or input.css references Tailwind) but the compiled output.css is missing.

Fix: Compile the CSS:

python manage.py djust_setup_css tailwind

In development, djust falls back to the Tailwind CDN automatically.


C012: Manual client.js in template

Severity: Warning

What causes it: A base/layout template manually includes <script src="{% static 'djust/client.js' %}">. djust automatically injects its client JS for LiveView pages.

What you see: Double-loading of client JavaScript, which can cause race conditions and duplicate WebSocket connections.

Fix: Remove the manual <script> tag. djust handles script injection automatically.


Validation Errors (V0xx)

V001: Missing template_name

Severity: Warning

What causes it: A LiveView subclass does not define template_name (and no parent class defines it either).

Fix:

class MyView(LiveView):
    template_name = "myapp/my_template.html"  # Add this

V002: Missing mount() method

Severity: Info

What causes it: A LiveView subclass does not define a mount() method. While not strictly required, mount() is where you initialize state.

Fix:

class MyView(LiveView):
    template_name = "my_template.html"

    def mount(self, request, **kwargs):
        self.count = 0

V003: Wrong mount() signature

Severity: Error

What causes it: The mount() method does not accept request as its first positional argument.

Fix:

# WRONG
def mount(self):
    pass

# WRONG
def mount(self, **kwargs):
    pass

# CORRECT
def mount(self, request, **kwargs):
    self.count = 0

V004: Missing @event_handler decorator

Severity: Info

What causes it: A public method name matches event handler naming patterns (e.g., handle_*, on_*, toggle_*, select_*, update_*, delete_*, create_*, add_*, remove_*, save_*, cancel_*, submit_*, close_*, open_*) but is not decorated with @event_handler.

Without the decorator, the method cannot be called from templates via dj-click or other directives.

Fix:

from djust.decorators import event_handler

class MyView(LiveView):
    @event_handler()
    def toggle_sidebar(self, **kwargs):
        self.sidebar_open = not self.sidebar_open

If the method is intentionally private (not callable from templates), prefix it with _:

def _toggle_internal_flag(self):
    self._flag = not self._flag

V005: Module not in LIVEVIEW_ALLOWED_MODULES

Severity: Warning

What causes it: You have set LIVEVIEW_ALLOWED_MODULES in settings and a LiveView's module is not in the list. WebSocket mount will silently fail for this view.

Fix:

# settings.py
LIVEVIEW_ALLOWED_MODULES = [
    "myapp.views",
    "otherapp.views",  # Add the missing module
]

Or remove LIVEVIEW_ALLOWED_MODULES entirely to allow all modules.


V006: Service instance in mount()

Severity: Warning

What causes it: AST analysis detected an assignment in mount() that instantiates a class whose name contains keywords like "Service", "Client", "Session", "API", or "Connection". These are typically not JSON-serializable.

What you see: The view works initially, but after a WebSocket reconnection or page refresh, you get AttributeError when trying to call methods on what is now a string representation of the object.

Fix: Use the helper method pattern:

# WRONG
class MyView(LiveView):
    def mount(self, request, **kwargs):
        self.s3 = boto3.client("s3")  # Not serializable

# CORRECT
class MyView(LiveView):
    def _get_s3(self):
        return boto3.client("s3")

    def mount(self, request, **kwargs):
        s3 = self._get_s3()  # Temporary variable, not stored in state
        # Use s3 here to fetch data

Or suppress if you know the object is serializable:

self.api_client = MySerializableClient()  # noqa: V006

Related: Working with External Services


V007: Event handler missing **kwargs

Severity: Warning

What causes it: An @event_handler decorated method does not include **kwargs in its signature. Event handlers receive all event parameters from the client, and without **kwargs, extra parameters will cause errors.

Fix:

# WRONG - will fail if client sends unexpected parameters
@event_handler()
def search(self, query: str = ""):
    self.results = search(query)

# CORRECT
@event_handler()
def search(self, query: str = "", **kwargs):
    self.results = search(query)

V008: Non-primitive type in mount()

Severity: Info

What causes it: AST analysis detected an assignment in mount() that instantiates a non-primitive type (not list, dict, set, tuple, str, int, float, bool). This may indicate a non-serializable object being stored in LiveView state.

What you see: Similar to V006, but catches a broader range of types. You may see runtime warnings about non-serializable values, or AttributeError after deserialization.

Fix: If the type is not JSON-serializable, use a private variable or helper method:

# WRONG
class MyView(LiveView):
    def mount(self, request, **kwargs):
        self.processor = DataProcessor()  # May not be serializable

# CORRECT - private variable
class MyView(LiveView):
    def mount(self, request, **kwargs):
        self._processor = DataProcessor()  # Private, not serialized

# OR - if it IS serializable, suppress the check
class MyView(LiveView):
    def mount(self, request, **kwargs):
        self.config = MySerializableConfig()  # noqa: V008

V008 is broader than V006 and will flag any custom class instantiation, not just service-like names. This helps catch subtle serialization bugs early.

Related: Working with External Services


Security Errors (S0xx)

S001: mark_safe() with f-string

Severity: Error

What causes it: Using mark_safe(f"...") with interpolated values. This is a cross-site scripting (XSS) vulnerability.

Fix:

# WRONG -- XSS vulnerability
from django.utils.safestring import mark_safe
html = mark_safe(f"<b>{user_input}</b>")

# CORRECT
from django.utils.html import format_html
html = format_html("<b>{}</b>", user_input)

S002: @csrf_exempt without justification

Severity: Warning

What causes it: A view function uses @csrf_exempt without a docstring explaining why CSRF protection is disabled.

Fix: Add a docstring mentioning "csrf" to acknowledge the exemption:

@csrf_exempt
def webhook_endpoint(request):
    """CSRF exempt: external webhook callback from payment provider."""
    pass

Or suppress the check on a specific line:

@csrf_exempt  # noqa: S002
def my_view(request):
    pass

S003: Bare except: pass

Severity: Warning

What causes it: A bare except: pass block that swallows all exceptions silently.

Fix:

# WRONG
try:
    do_something()
except:
    pass

# CORRECT -- catch specific exception and log
try:
    do_something()
except ValueError as e:
    logger.warning("Failed: %s", e)

S004: DEBUG=True with non-localhost ALLOWED_HOSTS

Severity: Warning

What causes it: DEBUG = True but ALLOWED_HOSTS includes non-local addresses. This exposes detailed error pages to external users.

Fix: Set DEBUG = False in production, or restrict ALLOWED_HOSTS to local addresses during development.


S005: State exposed without authentication

Severity: Warning

What causes it: A LiveView exposes public state variables but has no authentication configured (no login_required, permission_required, or check_permissions override).

Fix:

class AdminDashboardView(LiveView):
    login_required = True  # Require login
    # or
    permission_required = "myapp.view_dashboard"  # Require specific permission

    template_name = "dashboard.html"

If the view is intentionally public, acknowledge it explicitly:

class PublicCounterView(LiveView):
    login_required = False  # Explicitly public

Note: Prior to #303, this check incorrectly warned on views with login_required = False. The check now correctly distinguishes between intentionally public views (False) and views that haven't addressed authentication at all (None).

Related: Authentication Guide


Template Errors (T0xx)

T001: Deprecated @event syntax

Severity: Warning

What causes it: A template uses the old @click, @input, @change, or @submit syntax instead of the current dj-click, dj-input, dj-change, dj-submit.

Fix:

<!-- WRONG (deprecated) -->
<button @click="increment">+1</button>

<!-- CORRECT -->
<button dj-click="increment">+1</button>

T002: Missing dj-root

Severity: Info

What causes it: A template contains djust directives (dj-click, dj-input, dj-change, dj-submit, dj-model) but no element has the dj-root attribute. The check skips templates that use {% extends %} since the root may be in a parent template.

What you see: Events fire but the DOM never updates. Server logs show DJE-053 warnings.

Fix: Add dj-root to the root element of your LiveView template:

<div dj-view="myapp.views.MyView" dj-root>
    <!-- content -->
</div>

Related: Template Requirements Guide


T003: Wrapper template using {% include %} instead of {{ liveview_content|safe }}

Severity: Info

What causes it: A wrapper template that references LiveView content uses {% include %} tags instead of the {{ liveview_content|safe }} variable.

Fix: Use {{ liveview_content|safe }} in wrapper templates to render LiveView content.


T004: document.addEventListener for djust events

Severity: Warning

What causes it: A template uses document.addEventListener('djust:...') for djust custom events. All djust:* events are dispatched on window, not document.

What you see: The event listener never fires. No error in the console -- completely silent failure.

Fix:

<!-- WRONG -- event never fires -->
<script>
document.addEventListener('djust:push_event', (e) => { ... });
</script>

<!-- CORRECT -->
<script>
window.addEventListener('djust:push_event', (e) => { ... });
</script>

T005: dj-view and dj-root on different elements

Severity: Warning

What causes it: A template has dj-view on one element and dj-root on a different element. These attributes must be on the same root element.

What you see: VDOM patches may not be applied correctly, or updates fail silently.

Fix:

<!-- WRONG -- different elements -->
<div dj-view="myapp.views.MyView">
    <div dj-root>
        <p>Content</p>
    </div>
</div>

<!-- CORRECT -- same element -->
<div dj-view="myapp.views.MyView" dj-root>
    <p>Content</p>
</div>

T010: dj-click used for navigation

Severity: Warning

What causes it: A template element uses dj-click together with navigation-related data attributes (data-view, data-tab, data-page, or data-section). This pattern suggests you're implementing navigation, which should use dj-patch instead for proper URL updates and browser history support.

What you see: Navigation works but the browser URL doesn't update, and the back button doesn't work as expected. Users cannot bookmark or share specific views.

Fix:

<!-- WRONG -- dj-click for navigation -->
<button dj-click="show_settings" data-view="settings">Settings</button>

<!-- CORRECT -- use dj-patch for navigation -->
<a href="?view=settings" dj-patch="handle_params">Settings</a>

In your LiveView, handle the URL parameter:

from djust.decorators import event_handler

class MyView(LiveView):
    def mount(self, request, **kwargs):
        self.current_view = request.GET.get('view', 'dashboard')

    @event_handler()
    def handle_params(self, **kwargs):
        # Called when URL changes via dj-patch
        self.current_view = self.request.GET.get('view', 'dashboard')

Related: Navigation Guide


Code Quality (Q0xx)

Q001: print() statement

Severity: Info

What causes it: A print() call was found in production code.

Fix: Use the logging module instead:

import logging
logger = logging.getLogger(__name__)

# WRONG
print(f"Processing {item}")

# CORRECT
logger.info("Processing %s", item)

Q002: f-string in logger call

Severity: Warning

What causes it: A logger call uses an f-string instead of %s-style formatting. f-strings are evaluated eagerly, even when the log level is disabled.

Fix:

# WRONG -- f-string evaluated even if DEBUG is off
logger.debug(f"Processing {expensive_repr(item)}")

# CORRECT -- lazy evaluation
logger.debug("Processing %s", item)

Q003: console.log without djustDebug guard

Severity: Info

What causes it: A JavaScript file contains console.log without a djustDebug check. djust's client JS should only log when debugging is explicitly enabled.

Fix:

// WRONG
console.log("Connected to", url);

// CORRECT
if (globalThis.djustDebug) {
    console.log("Connected to", url);
}

Audit / Static Security Checks (A0xx)

These checks were added as follow-ups to the 2026-04-10 NYC Claims penetration test. They extend djust_audit / djust_check with configuration-level security checks that catch misconfigurations Django's own check --deploy cannot see. All A0xx checks fire during manage.py check --tag djust and appear alongside the C0xx/S0xx findings.

A001: WebSocket router missing AllowedHostsOriginValidator

Severity: Error

What causes it: The "websocket" entry in your ProtocolTypeRouter is not wrapped in channels.security.websocket.AllowedHostsOriginValidator. Any cross-origin page on the internet can open a WebSocket to your app, mount any LiveView, and dispatch events from a victim's browser (CSWSH — see #653).

What you see: No runtime symptom until a pentester or attacker finds it. An A001 warning from manage.py check at startup.

Fix:

# asgi.py
from channels.routing import ProtocolTypeRouter, URLRouter
from djust.routing import DjustMiddlewareStack   # Wraps in AllowedHostsOriginValidator by default since 0.4.1

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": DjustMiddlewareStack(
        URLRouter(websocket_urlpatterns)
    ),
})

Or wrap manually if you're using channels.auth.AuthMiddlewareStack:

from channels.security.websocket import AllowedHostsOriginValidator

"websocket": AllowedHostsOriginValidator(
    AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),

Prerequisite: settings.ALLOWED_HOSTS must not contain "*".


A010: ALLOWED_HOSTS is only ["*"] in production

Severity: Error

What causes it: settings.ALLOWED_HOSTS == ["*"] with DEBUG=False. The wildcard disables Django's Host header defense entirely, and re-opens CSWSH because AllowedHostsOriginValidator reads the same setting.

Fix: Set ALLOWED_HOSTS to the explicit hostnames your app serves:

# settings.py
ALLOWED_HOSTS = ["myapp.example.com", "api.example.com"]

A011: ALLOWED_HOSTS mixes "*" with explicit hosts

Severity: Error

What causes it: settings.ALLOWED_HOSTS = ["myapp.example.com", "*"]. Django accepts any Host header as soon as "*" is present — the explicit hostname is meaningless once the wildcard is in the list. Authors often mix them thinking they're "also allowing the explicit host for clarity."

Fix: Remove "*" and keep only the explicit hostnames.


A012: USE_X_FORWARDED_HOST=True + wildcard ALLOWED_HOSTS

Severity: Error

What causes it: USE_X_FORWARDED_HOST=True makes Django trust the X-Forwarded-Host header. Combined with wildcard ALLOWED_HOSTS, there is no validation of that header — attackers can inject any Host.

Fix: Set ALLOWED_HOSTS to explicit hostnames, or set USE_X_FORWARDED_HOST=False if your reverse proxy is not configured to set it safely.


A014: SECRET_KEY starts with "django-insecure-" in production

Severity: Error

What causes it: The Django scaffold default SECRET_KEY is a placeholder that starts with "django-insecure-". It's meant to be replaced before deployment. An attacker who knows the value (anyone with access to the source repo) can forge session cookies and password-reset tokens.

Fix: Generate a new key and load it from an environment variable:

import os
from django.core.management.utils import get_random_secret_key

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()

Then set DJANGO_SECRET_KEY in your deployment environment.


A020: LOGIN_REDIRECT_URL hardcoded with multi-group auth

Severity: Warning

What causes it: LOGIN_REDIRECT_URL is a single hardcoded path (e.g. /dashboard/) but the project uses a role-based auth model (detected via known packages like rolepermissions, rules, guardian, or via multiple entries in the Group table). All roles land on the same page after login — both a UX problem and a strong signal that per-role access control wasn't considered.

What you see: A lower-privilege user logs in and lands on the same dashboard as a supervisor, immediately exposing any broken RBAC. This is how the NYC Claims pentest team found broken role isolation in minutes.

Fix: Subclass django.contrib.auth.views.LoginView and override get_success_url():

from django.contrib.auth.views import LoginView
from django.urls import reverse

class RoleAwareLoginView(LoginView):
    def get_success_url(self):
        user = self.request.user
        if user.groups.filter(name="Supervisor").exists():
            return reverse("supervisor-dashboard")
        if user.groups.filter(name="Examiner").exists():
            return reverse("examiner-dashboard")
        return reverse("claimant-dashboard")

A030: django.contrib.admin without brute-force protection

Severity: Warning

What causes it: django.contrib.admin is in INSTALLED_APPS but no known brute-force protection package is present. The stock Django admin has no built-in rate limiting or lockout.

Fix: Install one of the recognized packages:

pip install django-axes
# settings.py
INSTALLED_APPS = [
    # ...
    "django.contrib.admin",
    "axes",
]

MIDDLEWARE = [
    # ...
    "axes.middleware.AxesMiddleware",
]

Recognized packages: axes, defender, brutebuster, ratelimit, django_ratelimit, django_axes.


Permissions Document Findings (P0xx)

These codes come from manage.py djust_audit --permissions permissions.yaml and validate the actual code against a committed declarative permissions document. See Declarative Permissions Document for the full setup guide.

P001: View declared in permissions.yaml but not found in code

Severity: Error

What causes it: An entry in permissions.yaml points at a dotted view path that no longer exists in the codebase. Probably a stale declaration after a view was removed or renamed.

Fix: Remove the stale entry from permissions.yaml, or restore the view if it was deleted by mistake.


P002: View found in code but not declared in permissions.yaml

Severity: Error (strict mode only)

What causes it: A new LiveView was added to the codebase without a corresponding entry in permissions.yaml, and the document has strict: true.

Fix: Add the view to permissions.yaml with its intended auth config:

views:
  myapp.views.NewView:
    public: true  # or: login_required: true, permissions: [...]

P003: Document says public but code has auth

Severity: Error

What causes it: permissions.yaml declares public: true for a view but the code sets login_required=True or permission_required=[...]. Either the declaration is out of date or someone added auth to a previously public view without updating the document.

Fix: Reconcile the two sources of truth — either remove the public: true and declare the actual auth config, or revert the code change if the view really should be public.


P004: Document says auth required but code has none

Severity: Error

What causes it: permissions.yaml declares login_required: true or permissions: [...] but the code has neither login_required=True nor permission_required, and there is no custom check_permissions() override or dispatch-based auth mixin.

Fix: Add the intended auth to the view class:

class MyView(LiveView):
    login_required = True
    permission_required = ["myapp.view_something"]

P005: Permission list mismatch

Severity: Error

What causes it: permissions.yaml lists one set of permissions for a view, but the code's permission_required attribute has a different set. Order is not significant; the comparison is set-equality.

Fix: Either update the document (if the code change was intentional) or revert the code (if the document is the source of truth).


P006: object_scoping field not referenced

Severity: Warning (currently informational)

What causes it: permissions.yaml declares object_scoping.fields: [...] for a view but the best-effort AST check couldn't find references to those fields in get_object() or equivalent. This is reserved for a future AST-verified implementation; today it's documentation-only.

Fix: Verify manually that your get_object() implementation scopes the query by the declared fields.


P007: Roles declaration (informational)

Severity: Info

What causes it: permissions.yaml lists roles: [...] for a view. djust cannot verify Django group membership at static-analysis time, so this is treated as documentation only. The info-level finding is recorded so reviewers can see which roles are expected.

Fix: Nothing to fix — the finding is informational. If you don't want to see these in output, filter by severity in CI.


Live Runtime Probe Findings (L0xx)

These codes come from manage.py djust_audit --live <url> and reflect actual runtime behavior of a deployed environment. They catch misconfigurations that static analysis cannot see — middleware correctly configured in settings.py but the response is stripped by nginx / ingress / proxy, or a firewall allows what settings appear to deny.

L001: Content-Security-Policy header missing

Severity: Error

What causes it: The live response has no Content-Security-Policy header. Common causes:

  1. csp.middleware.CSPMiddleware is not in MIDDLEWARE in the active settings module.
  2. An ingress / reverse proxy is stripping the header on the way out.
  3. A response middleware runs after CSPMiddleware and removes it.

Fix: Verify the header appears in a dev curl -sI http://localhost:8000/. If it does, the reverse proxy is the culprit — check your nginx/ingress configuration for proxy_hide_header or CloudFront header stripping.


L002: CSP contains 'unsafe-inline'

Severity: Warning

What causes it: script-src or style-src in the CSP header includes 'unsafe-inline', which negates most of CSP's XSS defense.

Fix: Enable nonce-based CSP (see #655 for djust's nonce support) and drop 'unsafe-inline':

# settings.py
CSP_INCLUDE_NONCE_IN = ("script-src", "script-src-elem", "style-src", "style-src-elem")
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'",)

L003: CSP contains 'unsafe-eval'

Severity: Warning

What causes it: script-src includes 'unsafe-eval', allowing dynamic code execution via eval(), new Function(), etc.

Fix: Remove 'unsafe-eval' from CSP_SCRIPT_SRC. If a third-party library requires it, isolate that library in an iframe with a separate CSP.


L004: Strict-Transport-Security header missing

Severity: Error (on HTTPS URLs only)

What causes it: The HTTPS response has no Strict-Transport-Security header. HTTP URLs are exempt from this check.

Fix:

# settings.py
SECURE_HSTS_SECONDS = 31_536_000  # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True

L005: HSTS max-age below 1 year

Severity: Warning

What causes it: Strict-Transport-Security: max-age=... is less than 31536000 (1 year). The HSTS preload list requires at least 1 year.

Fix: Set SECURE_HSTS_SECONDS = 31_536_000 (or higher) in settings.py.


L006: HSTS missing 'includeSubDomains'

Severity: Info

What causes it: The HSTS header doesn't include includeSubDomains, so subdomains are not protected.

Fix: SECURE_HSTS_INCLUDE_SUBDOMAINS = True in settings.py.


L007: HSTS missing 'preload'

Severity: Info

What causes it: The HSTS header doesn't include preload, so the domain can't be added to the HSTS preload list.

Fix: SECURE_HSTS_PRELOAD = True in settings.py, then submit to hstspreload.org.


L008: X-Frame-Options header missing

Severity: Error

What causes it: No X-Frame-Options header. Your pages can be framed by any other site, enabling clickjacking.

Fix:

# settings.py
X_FRAME_OPTIONS = "DENY"  # or "SAMEORIGIN"

Make sure django.middleware.clickjacking.XFrameOptionsMiddleware is in MIDDLEWARE.


L009: X-Content-Type-Options missing or wrong value

Severity: Error

What causes it: The response has no X-Content-Type-Options header, or its value is not nosniff. Browsers may MIME-sniff content and render uploaded files as HTML.

Fix: SECURE_CONTENT_TYPE_NOSNIFF = True in settings.py (this is Django's default in modern versions).


L010: Referrer-Policy header missing

Severity: Warning

What causes it: No Referrer-Policy header. The default browser behavior may leak full URLs in the Referer header of outgoing requests.

Fix: SECURE_REFERRER_POLICY = "same-origin" (or "strict-origin-when-cross-origin").


L011: Permissions-Policy header missing

Severity: Warning

What causes it: No Permissions-Policy header. Modern browsers allow the site to restrict access to powerful APIs (camera, microphone, geolocation, payment) — without this header, any same-origin script can request them.

Fix: Add a middleware that emits:

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()

L012: Cross-Origin-Opener-Policy missing

Severity: Info

What causes it: No Cross-Origin-Opener-Policy header. This isolates your window from cross-origin windows that open it (important for protecting against Spectre-class attacks).

Fix: SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin" in settings.py.


L013: Cross-Origin-Resource-Policy missing

Severity: Info

What causes it: No Cross-Origin-Resource-Policy header on the response. Your resources may be embedded by arbitrary cross-origin pages.

Fix: Set via a middleware or reverse proxy header: Cross-Origin-Resource-Policy: same-site.


L014: Server header leaks version

Severity: Warning

What causes it: The Server response header contains something that looks like a version string (nginx/1.25.3, Apache/2.4.58, Python/3.12, uvicorn/0.23, etc.). Attackers can match the version to known CVEs.

Fix: Configure your reverse proxy to suppress the version:

# nginx
server_tokens off;

L015: X-Powered-By header present

Severity: Warning

What causes it: The response includes X-Powered-By, typically revealing the framework/language. Useful information for attackers; not useful for anything else.

Fix: Configure your reverse proxy to strip the header, or remove it at the application layer.


Severity: Error

What causes it: The sessionid (or equivalent) cookie doesn't have the HttpOnly attribute. JavaScript can read it via document.cookie, enabling session theft via XSS.

Fix: SESSION_COOKIE_HTTPONLY = True (Django's default).


Severity: Error (on HTTPS URLs only)

What causes it: On an HTTPS response, the session cookie doesn't have the Secure attribute. The cookie may be sent over plaintext HTTP if the user's connection is downgraded.

Fix: SESSION_COOKIE_SECURE = True in settings.py.


Severity: Warning

What causes it: The session cookie has no SameSite attribute. Modern browsers default to Lax in most cases, but explicit declaration is safer.

Fix: SESSION_COOKIE_SAMESITE = "Lax" (or "Strict").


Severity: Error

What causes it: The csrftoken cookie doesn't have the HttpOnly attribute.

Fix: CSRF_COOKIE_HTTPONLY = True in settings.py.


Severity: Error (on HTTPS URLs only)

What causes it: The CSRF cookie doesn't have Secure on an HTTPS response.

Fix: CSRF_COOKIE_SECURE = True in settings.py.


L040: /.git/config publicly accessible

Severity: Error

What causes it: A GET /.git/config request returns 2xx. The entire git history of your repository is exposed, including potentially-leaked credentials in commits.

Fix: Configure your reverse proxy to deny access to /.git/:

# nginx
location ~ /\.git { deny all; return 404; }

L041: /.env publicly accessible

Severity: Error

What causes it: A GET /.env request returns 2xx. Environment variable files typically contain secrets (database passwords, API keys).

Fix: Never serve .env files from your web root. Store them outside the static root, and configure your reverse proxy to 404 the path.


L042: Django debug toolbar exposed

Severity: Error

What causes it: A GET /__debug__/ request returns 2xx in production. The Django Debug Toolbar is installed and accessible.

Fix: Guard the debug toolbar with DEBUG=True and INTERNAL_IPS:

# settings.py
if DEBUG:
    INSTALLED_APPS += ["debug_toolbar"]
    MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
    INTERNAL_IPS = ["127.0.0.1"]

L043: /robots.txt not present

Severity: Info

What causes it: /robots.txt returns 404. Not a security issue — an informational finding suggesting you add one to control crawler behavior explicitly.

Fix: Serve a robots.txt at the web root if you want to set crawl rules.


L044: /.well-known/security.txt not present (RFC 9116)

Severity: Info

What causes it: /.well-known/security.txt returns 404. RFC 9116 recommends publishing a security contact file for security researchers.

Fix: Create security.txt at your web root:

Contact: security@example.com
Expires: 2027-01-01T00:00:00Z
Preferred-Languages: en

L060: WebSocket accepted cross-origin handshake (CSWSH)

Severity: Error

What causes it: The runtime probe opened a WebSocket handshake with Origin: https://evil.example and the server accepted it. This is the runtime confirmation of #653 — the static check A001 can see whether the middleware is configured, but only this runtime probe can confirm the handshake is actually rejected end-to-end.

Fix: Wrap the WebSocket router in AllowedHostsOriginValidator (or use DjustMiddlewareStack, which does it by default since 0.4.1). See A001 above.


L061: WebSocket probe skipped (--no-websocket-probe)

Severity: Info

What causes it: The probe was disabled via --no-websocket-probe. Recorded as info so the audit report shows which checks ran and which were skipped.


L062: WebSocket probe skipped (websockets package not installed)

Severity: Info

What causes it: The websockets Python package is not installed, so the CSWSH probe can't run.

Fix: pip install websockets if you want to enable this check. The rest of the runtime probe still runs without it.


L090: Target URL unreachable

Severity: Error

What causes it: urllib couldn't connect to the target — DNS failure, connection refused, timeout, or similar network error.

Fix: Verify the URL is correct, the server is running, and your network can reach it. Check for typos in the hostname or port.


L091: Target URL returned non-2xx status

Severity: Error

What causes it: The target URL returned an HTTP 4xx/5xx status. The probe still inspects the response headers, but the finding is recorded so the report reflects that the main page wasn't loaded successfully.

Fix: Investigate the application error. If the root URL requires auth, pass credentials with --header 'Authorization: Basic ...'.


AST Anti-Pattern Scanner Findings (X0xx)

These findings are emitted by manage.py djust_audit --ast, which walks your Python source and Django templates looking for five specific security anti-patterns. Every pattern here was either a live vulnerability or a near-miss in the 2026-04-10 pentest of NYC Claims. The checks are intentionally narrow: false positives are worse than missed findings for a linter that runs on every push.

Suppress a single finding with # djust: noqa X001 on the offending line (or {# djust: noqa X006 #} inside a template). Bare # djust: noqa suppresses every djust.X finding on that line.

Run:

python manage.py djust_audit --ast                              # scan cwd
python manage.py djust_audit --ast --ast-path src/              # scan a directory
python manage.py djust_audit --ast --json                       # machine-readable
python manage.py djust_audit --ast --strict                     # fail on warnings too
python manage.py djust_audit --ast --ast-exclude vendor legacy/ # skip paths
python manage.py djust_audit --ast --ast-no-templates           # .py only

X001: Possible IDOR — object lookup by URL param without auth scoping

Severity: Error

What causes it: A view class whose name ends in DetailView/EditView (or which inherits from LiveView, DetailView, UpdateView, DeleteView, FormView) calls Model.objects.get(pk=self.kwargs["pk"]) without a sibling .filter(owner=request.user) (or user=, tenant=, organization=, team=, created_by=, author=, workspace=) scoping the queryset, and without calling check_permissions or has_perm.

What you see:

ERROR [djust.X001] /path/crm/views.py:42:8 Possible IDOR — object lookup by URL param without auth scoping (ContactDetailView.get_object: add .filter(owner=request.user) or override check_permissions() to scope by owner)

Fix: Scope the queryset by the authenticated user or the tenant the user belongs to:

class ContactDetailView(LiveView):
    def mount(self, request, pk):
        self.contact = Contact.objects.filter(
            owner=request.user
        ).get(pk=pk)

Or override check_permissions() with an explicit membership check and add # djust: noqa X001 on the .get() call if the scoping is implicit (e.g. the tenant filter sits inside a helper manager).


X002: Event handler mutates state without a permission check

Severity: Warning

What causes it: A method decorated with @event_handler contains a call to .create(), .update(), .delete(), .bulk_create(), .bulk_update(), or .save(), and neither of the following is true:

  • The enclosing class sets login_required = True or permission_required = "..." in the class body.
  • The handler itself is wrapped in @permission_required("..."), @login_required, @user_passes_test(...), @staff_member_required, or @superuser_required.

What you see:

WARN [djust.X002] /path/projects/views.py:87:4 Event handler mutates state without a permission check (ProjectView.delete_project: add @permission_required('projects.delete_project'), set login_required=True on the view, or override check_permissions())

Fix: Add class-level auth or a handler decorator:

class ProjectView(LiveView):
    login_required = True
    permission_required = "projects.manage_projects"

    @event_handler
    def delete_project(self, project_id: int):
        Project.objects.filter(pk=project_id).delete()

Or the more granular form:

@permission_required("projects.delete_project")
@event_handler
def delete_project(self, project_id: int):
    Project.objects.filter(pk=project_id).delete()

X003: SQL string formatting in raw()/extra()/execute() — SQLi risk

Severity: Error

What causes it: A call to .raw(...), .extra(...), cursor.execute(...), or cursor.executemany(...) passes a query built with an f-string, a .format() call, or a "..." % ... binary-op. Any of those interpolate Python values directly into the SQL string, bypassing Django's ORM parameter binding.

What you see:

ERROR [djust.X003] /path/reports/queries.py:19:11 SQL string formatting in raw()/extra()/execute() — SQLi risk (.raw(...) with interpolated string)

Fix: Use parametrised queries — pass the values as a second argument and leave %s placeholders in the SQL:

# BAD
Thing.objects.raw(f"SELECT * FROM thing WHERE name = '{name}'")

# GOOD
Thing.objects.raw("SELECT * FROM thing WHERE name = %s", [name])

# GOOD (cursor)
cursor.execute("SELECT * FROM t WHERE name = %s", [name])

X004: Open redirect — request data fed to redirect without is_safe_url

Severity: Error

What causes it: A function contains HttpResponseRedirect(request.GET[...]) (or request.POST, request.GET.get(...), etc.) and the enclosing function does not call url_has_allowed_host_and_scheme or is_safe_url anywhere inside it. The scanner looks for the guard at the same function scope — moving the check into a helper works, but add # djust: noqa X004 on the redirect line so reviewers can see the intent.

What you see:

ERROR [djust.X004] /path/auth/views.py:55:15 Open redirect — request data fed to redirect without is_safe_url (post_login: wrap the target in url_has_allowed_host_and_scheme() before redirecting)

Fix: Validate the URL before redirecting:

from django.utils.http import url_has_allowed_host_and_scheme

def post_login(request):
    target = request.GET.get("next", "/")
    if not url_has_allowed_host_and_scheme(
        target, allowed_hosts={request.get_host()}, require_https=request.is_secure()
    ):
        target = "/"
    return HttpResponseRedirect(target)

X005: mark_safe() with interpolated value — XSS risk

Severity: Error

What causes it: django.utils.safestring.mark_safe(...) or SafeString(...) is called with an argument that is an f-string, a .format() call, or a "..." % ... binary-op. Whatever gets interpolated becomes trusted HTML — if any piece of it is user-controlled, that's stored XSS.

What you see:

ERROR [djust.X005] /path/views.py:72:15 mark_safe() with interpolated value — XSS risk (mark_safe(...) wraps an interpolated string)

Fix: Use django.utils.html.format_html() instead — it behaves like .format() but escapes every substitution:

from django.utils.html import format_html, escape

# BAD
return mark_safe(f"<b>{name}</b>")

# GOOD
return format_html("<b>{}</b>", name)

# ALSO GOOD
return mark_safe(f"<b>{escape(name)}</b>")

X006: Template uses |safe on a view variable

Severity: Warning

What causes it: A .html template contains {{ variable|safe }}. The scanner flags every instance as "worth reviewing" — sometimes |safe is correct (rendering stored HTML that the author wrote in a CMS that already sanitizes it), but it is also the most common XSS foot-gun in Django apps.

What you see:

WARN [djust.X006] /path/templates/blog/post.html:14:4 Template uses |safe on a view variable ({{ body|safe }})

Fix: Remove |safe if the value is user-controlled. If the value is a short snippet you control, wrap it in format_html() on the view side and pass a SafeString instead. Suppress the finding with {# djust: noqa X006 #} on the same line if you have verified the HTML is already sanitised by bleach / nh3.


X007: Template uses {% autoescape off %}

Severity: Warning

What causes it: A .html template opens an {% autoescape off %} block. That disables Django's default auto-escaping for every {{ var }} inside the block — one forgotten |escape turns into stored XSS.

What you see:

WARN [djust.X007] /path/templates/emails/body.html:3:0 Template uses {% autoescape off %}

Fix: Remove the {% autoescape off %} block. If you really need unescaped output (rendering pre-escaped content from a trusted source), prefer an explicit {{ var|safe }} on the one variable that needs it — then X006 documents the exception — instead of a block that silently bypasses escaping for everything inside. Suppress with {# djust: noqa X007 #} on the block opener line if the exception is intentional.


Runtime Errors (DJE-xxx)

These errors appear in server logs during WebSocket communication and VDOM diffing. They are not caught by manage.py check -- they only occur at runtime.

DJE-050: Mixed keyed/unkeyed children

Severity: Warning (performance)

What causes it: A parent element has some children with data-key attributes and some without. This forces the VDOM to use a less efficient diffing strategy.

What you see: A trace-level warning in server logs (visible with DJUST_VDOM_TRACE=1). The page still works but updates may be slower than necessary for large lists.

Fix: Either add data-key to all sibling elements or remove keys from all of them:

<!-- WRONG: mixed keyed/unkeyed -->
<ul>
    <li>Static header</li>
    {% for item in items %}
    <li data-key="{{ item.id }}">{{ item.name }}</li>
    {% endfor %}
</ul>

<!-- CORRECT: all keyed (move static content outside the list) -->
<p>Static header</p>
<ul>
    {% for item in items %}
    <li data-key="{{ item.id }}">{{ item.name }}</li>
    {% endfor %}
</ul>

DJE-051: Duplicate keys

Severity: Warning

What causes it: Two or more sibling elements share the same data-key value. The VDOM engine cannot distinguish between them.

What you see: Trace-level warning in server logs. Elements may be updated incorrectly or not at all.

Fix: Ensure every data-key within the same parent is unique:

# WRONG: duplicate IDs possible if items have same id
{% for item in items %}
<li data-key="{{ item.name }}">{{ item.name }}</li>
{% endfor %}

# CORRECT: use unique identifier
{% for item in items %}
<li data-key="{{ item.id }}">{{ item.name }}</li>
{% endfor %}

DJE-052: Unkeyed list performance warning

Severity: Warning (performance)

What causes it: A large unkeyed list (10+ children) produced patches for more than half its children. This usually means the list was reordered, and without keys the VDOM had to patch every element individually.

What you see: Trace-level warning. The page still works but updates may be visually janky or slow for large lists.

Fix: Add data-key to list items:

<ul>
    {% for item in items %}
    <li data-key="{{ item.id }}">{{ item.name }}</li>
    {% endfor %}
</ul>

Related: List Reordering Performance


DJE-053: No DOM changes

Severity: Warning

What causes it: An event handler modified state and triggered a re-render, but the VDOM diff produced zero patches. This usually means the modified state is rendered outside the dj-root element (e.g., in base.html).

What you see: In server logs:

WARNING [djust] Event 'toggle_sidebar' on DashboardView produced no DOM changes (DJE-053).
The modified state may be outside <div dj-root>.

The page does not update even though the event handler ran and changed state.

Common causes:

  1. State rendered outside the VDOM root: The {% if show_panel %} block is in base.html while dj-root is in the child template.
  2. Missing dj-root: The attribute is not on any element, so the VDOM has no root to diff against.
  3. Event handler changes state that does not affect the template: The handler updates a variable that is not used in the template.

Fix:

  1. Move the affected template code inside the dj-root element
  2. Use push_event for UI state changes that live outside the VDOM root
  3. Check that dj-root is present on the root element

Related: Template Requirements Guide


Debugging Steps

When you encounter an error, follow this workflow:

1. Run system checks

python manage.py check --tag djust

This catches configuration, validation, security, template, and code quality issues at once.

2. Enable VDOM tracing for runtime issues

DJUST_VDOM_TRACE=1 python manage.py runserver

This produces detailed output for every VDOM diff, including tree structures, patch lists, and performance data. Look for DJE-xxx codes in the output.

3. Check browser DevTools

Open the browser's Network tab and filter by "WS" (WebSocket). You should see:

  • A WebSocket connection to your server
  • Messages flowing back and forth when you click buttons

If there is no WebSocket connection, check C001-C005. If events are sent but no patches come back, check DJE-053 and T002.

4. Check server logs

djust logs warnings for common issues. Look for lines starting with [djust]:

python manage.py runserver 2>&1 | grep "\[djust\]"

5. Run the security audit

For a comprehensive review of authentication and exposed state:

python manage.py djust_audit

Suppressing Checks

You can suppress specific checks on individual lines using # noqa:

# Suppress a specific check
mark_safe(f"<b>{trusted_value}</b>")  # noqa: S001

# Suppress all checks on a line
print("Debug output")  # noqa

# Suppress multiple specific checks
some_code()  # noqa: Q001,S003

See Also