Custom Middleware vs django-axes for Password Expiration Enforcement in Django 5.1
0 reputation · 10 Jul 2025, 19:51 UTC
0 reputation · 10 Jul 2025, 19:51 UTC
Django's authentication framework provides model-level permissions but leaves credential expiration policies entirely to the developer. Two documented patterns exist for rejecting stale passwords: a lightweight custom middleware that inspects user.last_password_change against a configured maximum age, or the django-axes package which adds AccessAttempt tracking, IP-based lockout, and signal-driven expiration handlers.
The middleware approach adds zero dependencies but requires maintaining a password-change timestamp field, an admin interface for forced resets, and test coverage for redirect loops on the password-change view. django-axes contributes roughly 3,000 lines of code yet supplies brute-force protection, an admin dashboard, and configurable cooldown periods out of the box.
A further complication arises from undefined backend ordering: Django does not specify whether a custom expiration backend should reject credentials before or after ModelBackend runs permission checks, creating ambiguous behavior when both are active.
Which approach better satisfies a requirement for compliance-grade password rotation without introducing operational fragility in a multi-tenant deployment behind corporate proxies?
django-axes handle shared-IP environments without false lockouts?35275 reputation · 11 Jul 2025, 01:44 UTC
For compliance-grade password rotation in a multi-tenant deployment behind corporate proxies, neither approach alone is sufficient. The recommended pattern combines a lightweight custom middleware (to enforce password-age redirects) with django-axes (to provide brute-force protection and audit logging). This hybrid avoids the operational fragility of maintaining a full expiration backend while gaining the lockout dashboard and proxy-aware IP handling that django-axes supplies.
The middleware pattern reliably avoids redirect loops only if it explicitly exempts the password-change URL (and the login URL) before checking user.last_password_change. In practice this means an allow-list of path names or resolver matches (e.g., request.resolver_match.url_name in {'password_change', 'login'}) at the top of the middleware. Without this exemption, an expired user hitting the password-change view—which itself requires authentication—will be redirected back to the same view indefinitely.
django-axes 5.x tracks AccessAttempt records keyed by IP address and/or username. To prevent false lockouts when multiple users share a corporate proxy, set:
AXES_ONLY_USER_FAILURES = True
AXES_LOCKOUT_BY_COMBINATION_USER_AND_IP = False
AXES_BEHIND_REVERSE_PROXY = True
AXES_PROXY_COUNT = 1 # adjust to your proxy chain depth
With these settings, lockouts are scoped to the username, not the shared IP, and the X-Forwarded-For header is parsed correctly.
Django 5.1 processes AUTHENTICATION_BACKENDS in list order; the first backend that returns a User instance wins. A custom expiration backend must be placed before ModelBackend and return None for expired credentials so the chain continues. However, if any earlier backend (including AxesBackend) returns a user, the chain short-circuits and ModelBackend never runs. Verified ordering for a combined setup:
AUTHENTICATION_BACKENDS = [
'myapp.backends.PasswordExpirationBackend', # returns None if expired
'axes.backends.AxesBackend', # logs failures, enforces lockout
'django.contrib.auth.backends.ModelBackend', # normal auth
]
Caution: This ordering is confirmed by Django 5.1's authentication documentation; no release-note changes alter the sequence.
password_change and login, simulate an expired user, and assert a single 302 to the password-change view without a second redirect.django-axes==5.3, configure the four settings above, then simulate five failed logins from the same IP with different usernames. Confirm no cross-user lockout occurs (check AccessAttempt records).PasswordExpirationBackend.authenticate returns None for an expired user and verify ModelBackend successfully authenticates a valid user.What is your tenant-isolation strategy (shared database with tenant middleware, separate databases, or schema-per-tenant)? This determines whether AccessAttempt queries must be scoped by tenant and whether the last_password_change field lives on a custom user model or a per-tenant profile.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.