Password Reset Flows: Why a uuid4 in a Database Row Is a Password That Never Expires

Password Reset Flows: Why a uuid4 in a Database Row Is a Password That Never Expires

Django Security Series β€” Post 14 | Series III: Authentication & Session
OWASP A07:2025 β€” Authentication Failures | Reading time: ~23 min

πŸ§ͺ Run it yourself. This attack ships as a runnable lab in django-security-lab: a hand-rolled reset flow beside Django's, and a victim whose password is deliberately strong β€” so guessing is not the way in. The lab seeds a reset token issued 400 days ago, the way a link survives in a proxy log. Replay it against the vulnerable endpoint and you own the account; replay it against the secure one and you get a 400. Then run Bandit and Semgrep over both views and count how many findings tell them apart.

Password reset is the authentication system you did not design. You spent weeks on login β€” throttling (Post 11), session rotation (Post 12), a password policy you actually read the standard for (Post 13) β€” and then, in an afternoon, you built a second door that issues credentials by email, and you built it out of a random string and a database row.

I went looking at the reset flow in PetiΓ§Γ£o Brasil before writing this, expecting to find something to confess. An account there is a publishing identity: it creates and publishes petitions, and it does not sign them β€” signing goes through gov.br's PKI, and the certificate is what makes a signature binding, not the session that reached the form. That bounds the damage without making it trivial: whoever completes a reset can publish in your name, and the reset flow is the shortest path to taking the account. What I found was mostly reassuring: the tokens come from Django's own generator, and the request endpoint declines to say whether an address is registered. The question nobody had asked was where the link points. A reset link has to name a domain, and when I traced this one, the domain came from the request, from the Host header the client sent. The only thing stopping a forged header from rewriting that link is ALLOWED_HOSTS, and its value lives in an environment variable on the server, not in the code. One careless change to that variable, a * added while debugging a deploy, and the reset email points wherever the attacker says, with no code change for a review or a test to catch.

That is the shape of this post. A reset flow has four largely independent ways to fail, and a codebase can get three of them right while the fourth quietly hands over accounts. Two of them live in the token: it stays valid too long, and it survives being spent. A third lives in the link, whose host the attacker supplied. The last one is not really about credentials at all. It is about the endpoint answering a question nobody asked it, and telling you who has an account here.

The unifying observation, and the thing that took me longest to see, is that none of these is a missing security feature. Every one of them is a question nobody remembered to ask about a value that was already sitting right there.


The Attack: What It Is and How It Works

Start from the property that makes this class different from everything else in Series III: a reset token is a credential that your application mails to a third party over an unauthenticated channel, and then accepts as proof of identity. Login credentials are chosen by the user and never transmitted by you. Session cookies are yours, set on the user's own browser, and expire. A reset token is a bearer credential you hand out on request, to anyone who can name an email address, and it authenticates whoever presents it. It is the only credential in the system with all three of those properties at once.

So the interesting questions are all about the lifetime of that bearer credential, and the naive implementation answers none of them. It is naive in a specific, recognisable way: generate a uuid4, save it in a row against the user, email a link containing it, and when the link comes back, look the row up and change the password. Every step of that is defensible. uuid4 is 122 bits of entropy from the operating system's cryptographically secure pseudorandom number generator (CSPRNG) β€” nobody is guessing it, and length is not the problem here and never was. Storing it server-side means you can revoke it. Scoping it to a user means one person's token cannot reset another person's password.

What the row does not know is anything about time. And the striking part is that, in this design, the schema does know. There is a created column, because somebody thought about expiry. There is a used_at timestamp, because somebody thought about replay. Both columns get written. Neither gets read by a branch that refuses anything. The bug is not a missing field; it is a missing if, in a function that already has the data in hand. That is why it survives code review β€” a reviewer looking at the model sees a well-designed table. Those two unread columns are the first two failures: the token never expires, and it can be used more than once. The other two are in how the link is built and in how the request endpoint answers.

The first failure is that the token never expires, because nothing reads created. The consequence is not that an attacker has more time to guess. They were never going to guess. The consequence is that a link which leaked stays live. And reset links leak constantly, because they are ordinary URLs and the web is extremely good at copying ordinary URLs into places nobody audits. They sit in reverse-proxy access logs, which are frequently shipped to a log aggregator with a two-year retention policy and a much broader access list than the database. They sit in browser history, which on a shared or corporate machine is not private. They pass through TLS-inspecting middleboxes on corporate networks, which is exactly the scenario where the "encrypted in transit" reassurance stops applying. They sit in mailbox backups that outlive the employee. And if the reset confirmation page loads any third-party resource β€” an analytics script, a web font, an embedded image β€” older browsers would put the full URL, token included, into the Referer header sent to that third party. Modern browsers default to strict-origin-when-cross-origin, which strips the path, and Django closes it twice over: SecurityMiddleware has sent Referrer-Policy: same-origin by default since 3.1, and PasswordResetConfirmView moves the token into the session and redirects to a set-password URL before rendering anything, so the token never sits in a Referer. This particular leak has largely closed; the others have not.

The second failure is that the token can be used again, because nothing reads used_at, and it makes the first one worse. A token that is never retired is not a one-time credential that leaked, it is a parallel password that the user does not know exists and cannot change, because changing the password does not delete or invalidate the token: it sits in a table of its own, which a password change never looks at. In the lab this is the whole exploit: the seeded token was issued 400 days ago, the vulnerable endpoint accepts it, and it accepts it again afterwards with a different password.

The third failure is earlier in the flow, where the link gets built: the link's domain comes from the attacker. This is Host header poisoning. The reset email has to contain an absolute URL, and an absolute URL needs a host. If the code asks the request for that host β€” request.build_absolute_uri() or request.get_host() with a permissive ALLOWED_HOSTS, or request.META['HTTP_HOST'] with any setting β€” then the domain in an email your server sends to a victim's inbox is a value the attacker supplied in a header. The attacker POSTs the victim's address to the reset form with Host: evil.test, the victim gets a genuine reset email from a genuine sender address, clicks the link because they did in fact click "forgot password" a moment ago, and their browser delivers the token to the attacker's server. There is no phishing page to detect and no lookalike domain in the sender; the only lie is in one link, in a message the real system really sent. Some variants do not even need the victim to act: a mail gateway or antivirus scanner that pre-fetches links in incoming mail follows the poisoned link itself and delivers the token without anyone clicking.

The fourth failure is that the request endpoint reveals who has an account, which is called account enumeration. It is the smallest of the four. "No account with that email" and "Check your inbox" are different answers, and a form that returns different answers becomes an oracle: in security jargon, something an attacker can query as often as they like for a yes-or-no answer about a secret, here whether an email has an account. Feed it a breach corpus of email addresses and you learn which of them have accounts on your site β€” which is a customer list, and depending on the site, a very sensitive one. A reset endpoint on a mental-health service, a dating platform or a political petition site leaks something more consequential than a username. This is the flaw people push back on most, usually with "the registration form leaks it anyway" β€” which is often true and is an argument for fixing the registration form, not for keeping a second oracle.

Any of the first three failures is enough on its own, and none of them needs password guessing, which is what separates this post from Posts 11 and 13. There is no wordlist, no rate limit to defeat, and no weak password required. The account's password can be a 30-character passphrase from a manager; it is irrelevant, because the attacker is not going to guess it. They are going to replace it.


Real-World Incidents

GitLab CVE-2023-7028 β€” unauthenticated account takeover via password reset, CVSS 10.0 (patched January 11, 2024; added to CISA's Known Exploited Vulnerabilities catalog May 1, 2024)

In GitLab 16.1.0 a feature landed that sounds unobjectionable: let users receive password reset emails at a secondary address. The implementation accepted the list of recipient addresses from the reset request and failed to verify that those addresses actually belonged to the account. The result was that an unauthenticated attacker could submit a reset request naming the victim's email and their own, and GitLab would mail a valid reset token to both.

The scoring is worth reading rather than skimming. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H comes to a flat 10.0: network-attackable, low complexity, no privileges required, and (the part that matters here) UI:N, no user interaction. Unlike the host-poisoning variant above, the victim does not have to click anything. The attacker asks for the reset, receives the token, sets a new password, and owns the account. GitLab scored the scope as changed (S:C), which reflects what a GitLab account usually holds: source code, CI/CD credentials, deployment keys, signing material. NVD's analysts disagreed on that point and rated it 9.8, with scope unchanged. Account takeover on a source-control platform is a supply-chain event, which is where a later post in the series picks this up.

Affected versions ran from 16.1 through 16.7.1; GitLab shipped fixes in 16.7.2, 16.6.4 and 16.5.6 on January 11, 2024, and backported to 16.1.6, 16.2.9, 16.3.7 and 16.4.5 β€” a backport fan-out that tells you how seriously they took it. Accounts with two-factor authentication were only partly spared: GitLab's own advisory notes that an attacker could still reset the password but would not clear the second factor. That is a precise and slightly uncomfortable statement of what MFA buys you here, and it is the argument for the next post.

MITRE ATT&CK maps the operative step as T1098 β€” Account Manipulation: the adversary modifies the credentials attached to an account they do not own, after which access proceeds as T1078 β€” Valid Accounts and is, from the application's point of view, indistinguishable from the real user logging in. I want to flag that the fit is imperfect, because the imperfection is informative. ATT&CK files T1098 under Persistence and Privilege Escalation β€” the framework's model is an adversary who is already inside and is manipulating accounts to stay there. A reset-flow flaw uses the same technique for initial access, and the mismatch is exactly what makes this class hard to detect operationally: the visible events are a password change and a successful login, the same ones a user who really forgot their password produces.

The regulatory reading from Brazil is the one I keep coming back to, because the legal question is not "were you breached" but "were your measures apt." LGPD Art. 46 requires processing agents to adopt security measures "aptas a proteger os dados pessoais de acessos nΓ£o autorizados" β€” apt to protect personal data from unauthorised access β€” and Art. 44, III measures that against "as tΓ©cnicas de tratamento de dados pessoais disponΓ­veis Γ  Γ©poca", the techniques available at the time. In this class the available measure is not merely available, it is the framework default: Django's PasswordResetTokenGenerator has shipped a time-limited, self-invalidating token since before most of us started writing Django. Choosing to hand-roll a uuid4 row instead is a decision a controller has to be prepared to justify, and "we did not think about it" is not a justification, it is the finding. Note also who carries the duty. Under LGPD the breach-notification obligation in Art. 48 falls on the controller β€” the entity that decides the purposes of processing β€” not on whoever operated the server. If you build the application and someone else runs it, the reset flow you shipped is still their exposure, and their lawyers will be reading your code.

Sources: GitLab β€” Critical Security Release: 16.7.2, 16.6.4, 16.5.6 (January 11, 2024) Β· CISA β€” Adds One Known Exploited Vulnerability to Catalog (May 1, 2024)


Django's Default Protections

Django's answer to the token problem is a design decision rather than a longer random string, and it is worth reading the actual source, because the entire behaviour falls out of five concatenated values. This is PasswordResetTokenGenerator._make_hash_value in 5.2:

# django/contrib/auth/tokens.py
def _make_hash_value(self, user, timestamp):
    login_timestamp = (
        ""
        if user.last_login is None
        else user.last_login.replace(microsecond=0, tzinfo=None)
    )
    email_field = user.get_email_field_name()
    email = getattr(user, email_field, "") or ""
    return f"{user.pk}{user.password}{login_timestamp}{timestamp}{email}"

That string is run through salted_hmac() with your SECRET_KEY, and the token you see in the URL is <timestamp-in-base36>-<hmac>. The generator has two methods: make_token(user) builds that string when the email goes out, and check_token(user, token) rebuilds it from the user's current state when the link comes back, accepting the link only if the two match and the timestamp is still within the timeout. Nothing is stored. There is no token table, no cleanup job, no used flag β€” and that is the point, because every one of the checks the hand-rolled version forgets is a consequence of what went into the hash.

The property I admire most is single use, because it is exactly the check hand-rolled flows forget, and here nobody has to write it. It works like this: the token is computed from the user's current password hash, user.password. When the reset completes, the password changes, and so does the hash. Because Django adds a random salt to every hash, that holds even if the user picks the same password: the stored hash still comes out different. From that moment, recomputing the token from the new hash gives a different result, so no token issued earlier matches any more, including the one the user just used. There is no "already used" flag to set and no if to forget: the token stops working because the data it was computed from has changed.

Expiry falls out the same way. The timestamp is hashed in and also carried in the clear at the front of the token, so check_token compares it against PASSWORD_RESET_TIMEOUT without touching the database. last_login buys a third property nobody asked for: a user who requests a reset, then remembers their password and signs in normally, has silently killed the link sitting in their inbox. Since 3.2 the email address is in the hash too, so changing the address kills every link already mailed to the old one, which matters when that mailbox is no longer the user's.

That default timeout is 259200 seconds, three days, and that is too long for a bearer credential: for three days, a leaked copy of a link the user has not used yet, found in a log, a browser history or a mailbox backup, is enough to take the account. Lower it with PASSWORD_RESET_TIMEOUT = 3600, one hour. The cost is small: anyone who opens the email later only has to request a new link.

Two details in check_token reward a second look:

for secret in [self.secret, *self.secret_fallbacks]:
    if constant_time_compare(
        self._make_token_with_timestamp(user, ts, secret),
        token,
    ):
        break
else:
    return False

The comparison uses constant_time_compare rather than == for a reason. == stops at the first character that differs, so a forged token with a correct beginning takes slightly longer to reject than one that is wrong from the start. An attacker who can measure that difference can recover a valid token one character at a time. constant_time_compare takes the same time whatever the input, so the timing reveals nothing.

The loop over secret_fallbacks exists for key rotation. Every token is signed with SECRET_KEY, so changing the key would instantly break every reset link already sitting in someone's inbox. SECRET_KEY_FALLBACKS lets you list the old keys: check_token tries the current key first and then each old one, so links signed before the rotation keep working until you remove the old key from the list.

That loop also makes the dependency plain: your reset tokens are only as secret as SECRET_KEY. The key is what stops anyone else from computing the HMAC, so an attacker who obtains it, and can also read the user's row from a database dump or backup, can produce valid reset links for any account. A later post in the series covers SECRET_KEY in full; simplejwt's SIGNING_KEY defaults to the same key, so one leak exposes both.

The host problem has a separate and older answer. Django's security documentation says it directly:

Django uses the Host header provided by the client to construct URLs in certain cases. While these values are sanitized to prevent Cross Site Scripting attacks, a fake Host value can be used for Cross-Site Request Forgery, cache poisoning attacks, and poisoning links in emails.

That is why ALLOWED_HOSTS exists. It arrived in Django 1.3.6 on February 19, 2013, and the release note is unusually candid about why: the project had been documenting how to configure your web server to reject bad Host headers, and then discovered that "even with the recommended web server configurations there are still techniques available for tricking many common web servers into supplying the application with an incorrect and possibly malicious Host header." So the validation moved into the framework, though 1.3.6 shipped it switched off, with a default of ['*'] for backwards compatibility. It is also the misconfiguration the host test below simulates.

And then the sentence that should make you go and grep your codebase:

This validation only applies via get_host(); if your code accesses the Host header directly from request.META you are bypassing this security protection.

In practice, request.get_host() and request.META['HTTP_HOST'] read the same header, but only the first one checks it. get_host() compares the value with ALLOWED_HOSTS and, if it is not on the list, raises an error and the request ends in a 400. request.META['HTTP_HOST'] returns the raw text the client sent, with no check at all. A link built from it ignores ALLOWED_HOSTS entirely, however correctly that setting is configured. That is why the grep is worth running: search your code for HTTP_HOST and replace each direct read with request.get_host() or, better, with a domain taken from configuration.

Finally, the flow Django ships ready-made. It has three pieces, one for each step of a reset. First, PasswordResetView shows the form where the user types their email. Next, PasswordResetForm looks up the account and sends the email with the link. Last, when the user clicks, PasswordResetConfirmView receives the link, checks the token and lets the user choose a new password.

Used together, these pieces deal with three of the four failures with no code of yours. The first two, the token that never expires and the reusable token, disappear because the link carries a token from the generator, which expires and works only once. The fourth, enumeration, is mostly closed: the response page is the same whether or not the address exists, though a timing difference remains, as Secure Implementation explains. On top of that, the form only emails active users with a usable password, so an account that signs in only through SSO, with no password of its own, gets no link.

The third failure, the link's domain, stays open. To build the link, the form needs a domain and looks for it in two places, in this order: the Django sites framework (django.contrib.sites), if it is installed; if not, request.get_host(), that is, the request's Host header, guarded only by ALLOWED_HOSTS. PasswordResetForm.save() does accept an argument, domain_override, that pins the domain, but PasswordResetView never passes it. The simplest way to close the gap is to give Django a fixed domain through the sites framework:

# settings.py
INSTALLED_APPS += ["django.contrib.sites"]
SITE_ID = 1

SITE_ID points at a row in the sites table, and Django creates that row with the domain example.com. Change it to your real domain, in the admin or in a data migration. From then on, the link's domain comes from that table, not from the request, and a forged Host header no longer affects it. The alternative, if you would rather not use the sites framework, is a subclass of PasswordResetView whose form_valid() passes domain_override to form.save(). That override has to replace the parent's form_valid() rather than call it, because the parent calls form.save() again, and that second email would be built from the request's host. Once that is done, there is almost no code left to write for this post's topic.


Vulnerable Pattern: What NOT to Do

Here is the hand-rolled flow, with the parts that matter marked. It is not a parody: taken one step at a time, everything in it is defensible, which is why it gets through code review. It is a trimmed version of what the lab ships as views_vulnerable.py.

# INSECURE β€” do not use in production
import uuid

def request_reset(request):
    email = request.POST.get("email", "")
    try:
        user = User.objects.get(email__iexact=email)
    except User.DoesNotExist:
        # FLAW 4 β€” the answer differs, so this endpoint is a membership oracle.
        return HttpResponse("no account with that email", status=404)

    row = ResetToken.objects.create(user=user, token=str(uuid.uuid4()))
    path = reverse("confirm", kwargs={"uidb64": ..., "token": row.token})

    # FLAW 3 β€” the host in this URL is whatever the client's Host header said.
    link = request.build_absolute_uri(path)
    send_mail("Reset your password", f"Reset here: {link}", FROM, [user.email])
    return HttpResponse("reset email sent")

Notice that the model behind it is fine:

class ResetToken(models.Model):
    user    = models.ForeignKey(User, on_delete=models.CASCADE)
    token   = models.CharField(max_length=36, unique=True)
    created = models.DateTimeField(default=timezone.now)   # never read
    used_at = models.DateTimeField(null=True, blank=True)  # never read

unique=True, a foreign key, a creation timestamp, a use timestamp. A schema review passes this without comment. Now the half that decides everything:

# INSECURE β€” do not use in production
def confirm(request, uidb64, token):
    user = user_from_uidb64(uidb64)
    row = ResetToken.objects.filter(token=token).first()
    if user is None or row is None or row.user_id != user.pk:
        return HttpResponse("invalid reset link", status=400)

    # FLAW 1 β€” nothing reads row.created.  The token never expires.
    # FLAW 2 β€” nothing reads row.used_at.  The token is infinitely reusable.

    user.set_password(request.POST["password"])
    user.save()

    row.used_at = timezone.now()      # written here, and read nowhere
    row.save(update_fields=["used_at"])
    return HttpResponse("password updated")

The row.used_at = timezone.now() line is the one I find most instructive. Somebody wrote it. They wrote it because they were thinking about single use β€” you do not add that column by accident. Then the branch that consumes it was left for later, and later did not happen, and what shipped is a flow that carefully records the evidence of its own bug.

One more variant, because it is the version that survives a partial fix. A team gets a pentest finding about expiry and patches it:

# STILL INSECURE
if timezone.now() - row.created > timedelta(hours=1):
    return HttpResponse("expired", status=400)

Expiry is now enforced and the report gets closed. The token is still reusable for an hour, still valid after the password changes, and the link is still built from the request host. Fixing one of four independent flaws leaves the other three in place.


Secure Implementation: The Django Way

The fix has two halves, one for each view in the flow. The confirm view, which receives the link, closes the first two failures. It ends up shorter than the broken version, because the whole check fits in one call:

# SECURE β€” recommended pattern
from django.contrib.auth.tokens import default_token_generator

def confirm(request, uidb64, token):
    user = user_from_uidb64(uidb64)
    # One call: signature, age, prior use, and an intervening login.
    if user is None or not default_token_generator.check_token(user, token):
        return HttpResponse("invalid or expired reset link", status=400)

    user.set_password(request.POST["password"])
    user.save()
    # No row to retire: the save above already invalidated this token,
    # because user.password is part of what the token hashes.
    return HttpResponse("password updated")

check_token rejects a tampered token, a token older than PASSWORD_RESET_TIMEOUT and a token that has already been used, with no extra if. The ResetToken model goes away with it: there is no created to read, no used_at to set and no cleanup job to maintain.

The request view, which receives the email address and sends the link, closes the other two. For the third failure, the link's domain now comes from configuration instead of the request:

# SECURE β€” the canonical domain is something you know, not something you are told
RESET_BASE_URL = "https://app.example.com"   # in settings.py, or the Site from django.contrib.sites

uid = urlsafe_base64_encode(force_bytes(user.pk))
token = default_token_generator.make_token(user)
link = RESET_BASE_URL + reverse("confirm", kwargs={"uidb64": uid, "token": token})

For the fourth, the response is the same whether or not the address exists:

# SECURE β€” same body, same status, whether or not the address matched
user = User.objects.filter(email__iexact=email, is_active=True).first()
if user is not None:
    send_reset_email(user)
return HttpResponse("If an account exists for that address, a reset link has been sent.")

That last fix has a limit. When the address exists, the view sends an email before it responds, and sending email is slow; when it does not, the view responds at once. An attacker who measures response times can still tell the two cases apart. Django's own PasswordResetForm has the same limit, because it also sends the email before the view responds. The complete fix is to put the sending on a task queue, so both responses go out in the same time. It is worth doing if your user list is sensitive in itself, and worth knowing either way, because a uniform message is often presented as if it had removed the oracle, and it has not.

On the domain, the two defences stack. ALLOWED_HOSTS should be a real list on every deployment, because it rejects a forged Host even when the code builds the link from the request. A domain from configuration protects the link even when ALLOWED_HOSTS is wrong, so keep both. On the token, prefer PasswordResetConfirmView to calling default_token_generator yourself: besides checking the token, the view moves it out of the URL before rendering the page, as described in the attack section.

If a ResetToken table already exists in production, the migration has a step that is easy to forget: delete the old rows in the same deploy that brings in the new generator. The temptation is to write a compatibility branch that accepts the new token and, for anyone still holding an old link, falls back to the table. That branch keeps every old link that has leaked valid for as long as it exists, and in practice it exists until someone remembers to remove it. If you cannot delete the rows on deploy day, give the branch a removal date and record it somewhere someone will check.


The Analyst's View

For an analyst, the problem is that this vulnerability leaves nothing suspicious in the status codes. Post 11's brute force produces a burst of 401s; an account takeover through the reset flow produces a reset request, a password change and a login, exactly like a legitimate user who forgot their password. The most useful signal is origin: a reset completed from an IP or country the account has never used, or from a different IP than the one that requested it, deserves an alert, although it produces false positives (the user requests on a laptop and clicks on a phone) and a VPN or residential proxy can disguise it. Beyond that, detection can target each failure: very old tokens in ResetToken (used_at long after created), rejections in the django.security.DisallowedHost logger, and bursts of 404s on the reset endpoint. These searches tend to appear only after an advisory, like GitLab's, which is why prevention remains the main defence.

What that leaves you, in vulnerability-management terms, is a finding you have to go and look for rather than wait for, and a specific question to ask of any application you inherit: does the reset flow use the framework's token, or a stored one? It is a quick first pass β€” check_token, or its absence beside a set_password β€” though not a clean partition: code that uses Django's views contains neither, and a check_token that is present may not guard the write. The compensating control, when you cannot change the code today, is MFA on the accounts that matter, and the GitLab advisory states plainly how much that compensates: the attacker still resets the password, but does not clear the second factor. That is a genuine reduction in impact and not a fix, which is the textbook definition of a compensating control and a useful thing to be able to say precisely to a risk owner.


Catching It Automatically

Testing Your Defence

Five tests cover the four flaws, and they are worth writing even if you use Django's views, because the thing they protect against is a future refactor that "simplifies" the flow. Each one goes through your view rather than straight to the generator. A test that only calls check_token() passes whether or not your view ever does, so it cannot catch that refactor.

from django.contrib.auth.tokens import default_token_generator

def test_a_spent_link_is_refused_on_replay(self):
    token = default_token_generator.make_token(self.user)
    first = self.client.post(self.confirm_url(token), {"password": "a new pass phrase"})
    self.assertEqual(first.status_code, 200)
    # The same link, a second time, through the view rather than the generator.
    second = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
    self.assertEqual(second.status_code, 400)

def test_token_dies_if_the_password_changes_elsewhere(self):
    token = default_token_generator.make_token(self.user)
    self.user.set_password("changed it in settings instead")
    self.user.save()
    resp = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
    self.assertEqual(resp.status_code, 400)

def test_token_expires(self):
    token = default_token_generator.make_token(self.user)
    later = datetime.now() + timedelta(seconds=settings.PASSWORD_RESET_TIMEOUT + 60)
    with mock.patch.object(default_token_generator, "_now", return_value=later):
        resp = self.client.post(self.confirm_url(token), {"password": "attacker's choice"})
    self.assertEqual(resp.status_code, 400)

def test_a_poisoned_host_cannot_change_the_link(self):
    with self.settings(ALLOWED_HOSTS=["*"]):          # the misconfiguration, on purpose
        self.client.post(self.request_url, {"email": self.user.email},
                         HTTP_HOST="evil.test")
    self.assertNotIn("evil.test", mail.outbox[-1].body)

def test_reset_request_does_not_reveal_membership(self):
    known = self.client.post(self.request_url, {"email": self.user.email})
    unknown = self.client.post(self.request_url, {"email": "nobody@example.test"})
    self.assertEqual((known.status_code, known.content),
                     (unknown.status_code, unknown.content))

The obvious way to write the first test is wrong. Posting the link and then asking check_token(self.user, token) fails against a correct view, because self.user is a stale in-memory copy that still holds the old hash. Add refresh_from_db() and it passes against a view that never calls check_token at all, because any password write invalidates the token. Only replaying the link through the view tells the two apart.

Two notes on the expiry and host tests, because neither works the obvious way. For expiry there is nothing to age β€” the token is not a row β€” so you move time, not the token; PasswordResetTokenGenerator._now() exists as a method for exactly this reason. And the ALLOWED_HOSTS=["*"] override is not a convenience to make the test pass. Without it, Django rejects the poisoned Host before your view ever runs, and the test passes for a reason that has nothing to do with your link builder. Overriding it removes the framework's backstop so the test actually measures the thing it claims to measure β€” and ["*"] is a real setting: Django 1.3.6 itself shipped it as the default.

Then the check that needs no test framework at all:

grep -rn "set_password" --include=*.py . | grep -v tests

Read every hit and ask one question: how did the caller establish that this request is allowed to change this password? For an authenticated "change your password" view the answer is the session. For anything reached by a token, the answer had better be check_token.

Scanning It

I expected this to be a short section. The decisive difference between the lab's two confirm views is one call, check_token(), which is the kind of thing static analysis is supposed to be good at.

Not one rule, in any tier, can tell them apart.

Bandit finds nothing in any view file. Five findings, all B105/B106 hardcoded-password hits on the lab's fixtures. That is the same blind spot as Post 13, arriving from the other direction: there the missing call was validate_password(), here it is check_token(), and absence has no AST node either way. One finding is worth quoting, though, because it is the closest any off-the-shelf tool gets:

>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: '6f1c1e2a-9b7d-4a3f-8c21-0d5e7a9b4c33'
   Location: labs/post_14_password_reset/seed.py:43:15

Bandit flagged it because the variable is named LEAKED_TOKEN: B105 matches any string assigned to a name containing token, secret or pass. The heuristic lands on this post's thesis, that a reset token is a credential, but only through the name. The property it objects to, a hardcoded value in a fixture, is a lab artefact and not the bug.

Semgrep was run at two levels. The first is the curated packs (p/django, p/python, p/owasp-top-ten). They return two findings, and they are the same rule in both views:

semgrep scan --config p/django --config p/python --config p/owasp-top-ten labs/post_14_password_reset/
# Ran 156 rules on 17 files: 2 findings.

The rule is use-none-for-password-default, and it flags the line new_password = request.POST.get("password", ""), which is identical in views_vulnerable.py:88 and views_secure.py:85. Its concern is that the "" default could reach set_password() and store an empty password. Here it is a false positive: the next line, if not new_password: return 400, rejects the empty password, and the rule does not look at that line. And even if the finding were true, it would say nothing about whether the token was verified.

The second level is the full registry rulesets (r/python.django and r/python), which include rules the curated packs leave out, many of them in the noisier audit subcategory. This is the level that found the bug in Posts 8 and 13. Here it runs 372 rules and returns 17 findings. Seven land in support files (the seed, the tests and the vault view). The other ten land in the two views, and each rule appears in both the same number of times:

Rule What it flags views_vulnerable.py views_secure.py
no-csrf-exempt use of @csrf_exempt L43, L76 L47, L77
unvalidated-password set_password() without validate_password() L92 L89
use-none-for-password-default a "" default for the password L88 L85
direct-use-of-httpresponse data sent straight into an HttpResponse, without a template L98 L94

None of these rules is about the token. Each one flags something the two views have in common, so the result is identical on both sides, and nothing in it shows that one of the views never calls check_token().

To check whether this was a trait of the bug class and not just of my two files, I ran the same five configs against the test file of the custom rule that appears further down. That file has six functions that call set_password(): three broken reset views, one of them the blind spot the rule accepts, and three correct ones, among them an ordinary "change your own password" view, where the user is already logged in and there is no token at all. Only one rule fired, unvalidated-password, and it fired on all six. Six functions, six findings, no distinction between the broken ones and the correct ones.

That does not mean the rule is wrong. unvalidated-password is about something else: password strength (CWE-521). It fires on every set_password() that is not preceded by a validate_password(), and none of the six functions calls validate_password(), so by its own criteria all six flags are correct. What is missing is a rule that asks whether the reset token was verified, and no registry rule asks that question. Answering it for any codebase would mean following the token through helper functions and class methods to the call that verifies it, which is called interprocedural analysis, and the community edition of Semgrep does not do it. Within a single function, though, a simple rule that only looks at the shape of the code is enough.

This is the series' "can't distinguish" outcome: Bandit misses the bug, and Semgrep flags the vulnerable code and the secure code the same way. That is the case that justifies writing a rule of your own. rules/password_reset.yaml looks for a function that takes a parameter with "token" in its name, calls set_password() and never calls check_token() anywhere in its body:

semgrep scan --config rules/password_reset.yaml \
  labs/post_14_password_reset/views_vulnerable.py \
  labs/post_14_password_reset/views_secure.py
# ❯❯❱ rules.thiagoteixeira.django.security.password-reset.reset-token-never-verified
#    labs/post_14_password_reset/views_vulnerable.py
# Ran 1 rule on 2 files: 1 finding.

It fires on the vulnerable view and stays quiet on the secure one. Requiring a parameter with "token" in its name is what keeps it away from legitimate change-your-own-password views, which call set_password() with no token at all, and which unvalidated-password flags along with everything else. Because the rule only looks at the shape of the code, it has known limits. Any check_token() in the body silences it, even if the result is ignored or the check runs against the wrong user. It misses a token whose parameter has a different name, and a token read from the POST body. And it fires by mistake on a class-based view that checks the token in dispatch() and changes the password in post().

One detail of the rule matters. To recognise check_token(), it uses Semgrep's deep-expression operator, <... $GEN.check_token(...) ...>, which finds the call anywhere inside a statement, including inside a condition such as if not default_token_generator.check_token(user, token):. Without it, the pattern would only recognise the call alone on a line or assigned to a variable, and the rule would flag exactly the secure view, written the most common way.


Password reset deserves an audit of its own, separate from the rest of your authentication code, because it is a second way into every account, built separately from login. The habit to carry forward is a single question, asked of every code path that reaches set_password(): what proved this request was allowed to do that? If the answer is a row in a table, go and read the branch that expires it β€” and if you cannot find that branch, you have found this post's vulnerability in your own codebase. Next in the series is multi-factor authentication, which is the control that was still standing in the GitLab advisory after the password had already been changed.

Further Reading

← Back to the series