Django: serve the change password well-known URL

When a password manager detects that a user’s password has been leaked or reused, it can prompt them to change it, but the password change URL varies by site. The web’s answer to such discovery problems is the reserved /.well-known/ URL namespace (RFC 8615), home to machine-readable files and endpoints like security.txt. A Well-Known URL for Changing Passwords is the web specification that uses this namespace to fix password page discovery. It reserves the URL path /.well-known/change-password to redirect to your actual change password page, wherever that lives.
Password managers that use this URL include Apple’s iCloud Keychain (in Safari since 2019), Google Password Manager (since Chrome 86, 2020), and 1Password. web.dev has an excellent article explaining the specification and showing the Google Password Manager feature in action.
In this post, we’ll look at implementing the change password URL in a Django project.
Add the redirect
The specification asks that /.well-known/change-password redirect to your change password page with a temporary redirect status code, for which you can use Django’s RedirectView. The pattern_name argument looks up the target URL by name, so the redirect stays correct even if you move the page.
So, to add a redirect, plop this path in your root URLconf:
from django.urls import path
from django.views.generic import RedirectView
urlpatterns = [
# ...
path(
".well-known/change-password",
RedirectView.as_view(pattern_name="password_change"),
),
# ...
]
Note the path has no trailing slash, per the specification and counter to Django’s default pattern. There’s also no need for a name= argument, since nothing on your site should link to the URL.
password_change is the URL name provided by django.contrib.auth.urls, which serves Django’s built-in PasswordChangeView. This assumes your URLconf includes those URLs, conventionally mounted at accounts/:
path("accounts/", include("django.contrib.auth.urls")),
If you serve your change password page some other way, swap in the appropriate URL name for pattern_name, such as account_change_password if you use django-allauth. Django doesn’t check pattern_name until a request arrives, so a wrong name here fails only when the URL is visited, hence the test below.
Check with runserver and visit http://localhost:8000/.well-known/change-password — you should land on your change password page (or the login page redirecting you there with ?next).
Password managers only ever visit the URL on your live site (with HTTPS), so after deploying, repeat the check on your production domain.
For an end-to-end check, Chrome’s password checkup tool (under Settings → Passwords) shows a “Change password” button for compromised entries, which should open your page directly once the redirect is deployed. To make the button appear without waiting for a real breach, temporarily change your saved password for the site to a deliberately weak one, like password123, which Chrome flags in the check.
Public users only
By the way, this feature is for public users and their password change pages, not admins. So don’t use this feature to redirect to your Django admin’s password change view, or any other private page. That would advertise those URLs to the world, undoing the common hardening of hosting Django’s admin at a non-default path.
Check the resource that should not exist
There’s a second URL to be aware of, a check for whether your server is broken. Some misconfigured servers respond with 200 to every request, serving an error page instead of using a proper 404 status code. On such a server, a client fetching /.well-known/change-password can’t tell whether it found a real change password page or an error page.
The specification solves this with a second reserved path, gloriously named:
/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200
Clients may request this URL to detect broken servers. If it responds with a 200, the server’s status codes are deemed meaningless, so the client ignores the change password URL and falls back to something cruder, like opening your homepage.
Django responds with a 404 for unmatched URLs, so your site should pass this check, with nothing to implement. But a catch-all URL pattern could break it, such as one serving pages from a CMS, or a single-page application fallback that serves index.html with a 200 for any path. So it’s worth covering with a test, included below.
Add tests
As ever, it’s best to include tests to guard against accidental breakage, such as removal of the URL. Here’s a test case covering both URLs:
from http import HTTPStatus
from django.test import SimpleTestCase
from django.urls import resolve
class ChangePasswordWellKnownTests(SimpleTestCase):
"""
Test the well-known URLs for changing passwords, per:
https://adamj.eu/tech/2026/09/16/django-change-password-url/
"""
def test_change_password(self):
response = self.client.get("/.well-known/change-password")
self.assertRedirects(
response,
"/accounts/password_change/",
fetch_redirect_response=False,
)
resolve(response["Location"]) # Check it’s a real URL
def test_resource_that_should_not_exist(self):
response = self.client.get(
"/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200"
)
self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)
Notes:
- Neither view uses the database, so the test case uses
SimpleTestCase, which blocks database access and runs a little faster. assertRedirectschecks both the status code, 302 by default, and the target URL.- The target URL is hardcoded, matching where the auth URLs were mounted earlier. If yours live elsewhere, adjust it. Hardcoding, rather than using
reverse(), makes the test check what clients see, rather than using any internal details of your system. - Passing
fetch_redirect_response=Falsestops the test client from following the redirect. Without it,assertRedirectswould fetch the change password page and fail, because when logged out that page responds with a second redirect, to the login page. resolve()raisesResolver404if the target URL doesn’t map to a view. Calling it makes up for the skipped fetch above, checking that the redirect points at a real page rather than a typo.
Check your form’s autocomplete attributes
The web.dev article also recommends annotating your change password form fields with autocomplete attributes, so password managers can fill in the current password and suggest a generated replacement:
autocomplete="current-password"on the current password fieldautocomplete="new-password"on the new password field(s)
If you use Django’s built-in PasswordChangeForm, it’s done for you, as the widgets there have included these attributes since Django 3.0.
But if you’ve built a custom form, it’s worth checking that its fields carry the right attributes. You can set them through the attrs argument of each field’s widget, for example:
from django import forms
class ChangePasswordForm(forms.Form):
current_password = forms.CharField(
widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
)
new_password = forms.CharField(
widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
)
...
If you’re customizing Django’s flow, prefer subclassing PasswordChangeForm, which carries those attributes already.
Fin
So there we go, a nice little standard to make your user’s security a little easier. Add one URL entry and password managers can shepherd your users away from compromised passwords.
May your data never leak and your users passwords always be strong,
—Adam
Read my book Boost Your Git DX to Git better.
One summary email a week, no spam, I pinky promise.
Related posts:
- Django: Add a .well-known URL
- How to Add a Favicon to Your Django Site
- Django: Detect the global privacy control signal
Tags: django