Django: serve the change password well-known URL

Sometimes you just gotta change the locks.

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 SettingsPasswords) 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:

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:

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.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: