skip to content

How does Django's PasswordResetTokenGenerator make reset links expire and work only once without storing any tokens in the database?

level: middleimportance: should knowfreq 40%

answer

  1. nothing is saved server-side
  2. a timestamp plus an HMAC
  3. hash input includes the password hash
  4. age checked against a setting
  5. last_login and email also count

basics

~20 s

The token is a base36 timestamp plus an HMAC of the user's pk, password hash, last_login, that timestamp and email. Changing the password changes the HMAC input, killing the link; check_token() rejects tokens older than PASSWORD_RESET_TIMEOUT (3 days).

solid answer

~40 s

`make_token(user)` returns `<timestamp in base36>-<hash>`. The timestamp is seconds since 2001-01-01; the hash is `salted_hmac()` with SHA-256, keyed by `SECRET_KEY` and a fixed `key_salt`, over `user.pk`, `user.password`, `last_login` (without microseconds), the timestamp and the user's email, truncated to every second hex character. `check_token()` recomputes the HMAC for that user and timestamp with the current secret and each of `SECRET_KEY_FALLBACKS`, then rejects the token if its age exceeds `PASSWORD_RESET_TIMEOUT`, which defaults to 259,200 seconds, 3 days. Nothing is stored: a used link fails because the new password changes `user.password`, and usually `last_login` changes soon after. Changing the email also invalidates outstanding links. Because the age is checked at use time, shortening the setting also shortens links already sent.

code

python · 12 lines
python
from django.contrib.auth.tokens import PasswordResetTokenGenerator


class LegacyMemberResetTokenGenerator(PasswordResetTokenGenerator):
    key_salt = 'members.tokens.LegacyMemberResetTokenGenerator'

    def _make_hash_value(self, user, timestamp):
        # security_stamp is a project field bumped on every account-security change
        return f'{super()._make_hash_value(user, timestamp)}{user.security_stamp}'


legacy_member_token_generator = LegacyMemberResetTokenGenerator()

go deeper

for a junior

Recall that reset links expire after PASSWORD_RESET_TIMEOUT, three days by default, and stop working once the password has been reset.

for a middle

Explain the token's parts, the HMAC inputs and the three checks in check_token(), and why this needs no database table.

for a senior

Customise _make_hash_value() for extra invalidation triggers, reason about lowering the timeout, and remember that rotating SECRET_KEY without fallbacks voids every link.

for a principal

Choose between stateless tokens and stored, revocable tokens when you need audit trails, per-token revocation or very short validity windows.

## The shape of a token `django.contrib.auth.tokens.default_token_generator` is an instance of **`PasswordResetTokenGenerator`**. `make_token(user)` produces: ```text <ts_b36>-<hash> ``` - **`ts_b36`** - the number of seconds since 2001-01-01, in base 36; the source notes this stays six characters until about 2069. - **`hash`** - `salted_hmac(key_salt, value, secret=SECRET_KEY, algorithm='sha256').hexdigest()[::2]`, keeping every second character to shorten the URL. The `key_salt` is the class path string `django.contrib.auth.tokens.PasswordResetTokenGenerator`, which separates these HMACs from other uses of the secret key. ## What goes into the HMAC `_make_hash_value(user, timestamp)` concatenates: | Input | Why it is there | |---|---| | `user.pk` | ties the token to one account | | `user.password` | changes whenever a new password is set, even the same password, because the salt is new | | `user.last_login` (microseconds dropped, timezone stripped) | changes when the user signs in after resetting | | the timestamp | makes the age tamper-proof | | the value of the user's email field | invalidates links after an email change | The docstring spells out the reasoning: the password field changes on reset, `last_login` usually changes shortly after, and failing both, `PASSWORD_RESET_TIMEOUT` eventually invalidates the token. ## How check_token() decides 1. Reject if the user or token is missing, or the token does not split into two parts, or the timestamp is not valid base 36. 2. Recompute the token for this user and timestamp with `SECRET_KEY`, then with each entry in `SECRET_KEY_FALLBACKS`; compare in constant time. No match means the link was tampered with or the user's state has changed. 3. Compute the age: now minus the timestamp. If it exceeds `PASSWORD_RESET_TIMEOUT`, reject. Only if all three pass is the token accepted. ## Why no database table is needed The token proves two things the server can recompute at any time: that it was issued by someone holding the secret key, and that the user's relevant state has not changed since. "Single use" is a consequence, not a stored flag: - once `SetPasswordForm` saves, `user.password` differs, so the same link fails; - a second link requested before the first was used remains valid until one of them is used, because both were made from the same state; - any other password change, for example through the admin, also voids outstanding links. ## PASSWORD_RESET_TIMEOUT - It is an integer number of **seconds**; the default `60 * 60 * 24 * 3` is 259,200, three days. - The older `PASSWORD_RESET_TIMEOUT_DAYS` setting was removed in Django 4.0. - Because the token carries its creation time rather than an expiry time, lowering the setting takes effect immediately for links already in inboxes. ## Customising Subclass the generator when your rules differ: - override `_make_hash_value()` to add state whose change should void links, such as a security-stamp field or an account-locked flag, calling `super()` to keep the built-in inputs; - set `algorithm` or `secret` on the subclass, or `key_salt` to separate its tokens from the default ones; - pass the instance to `PasswordResetView` and `PasswordResetConfirmView` via `token_generator`, and reuse the pattern for email-verification links with a different `key_salt`.

  • If a user requests two reset emails and uses the second link, does the first one still work?
    No. Both tokens were computed from the same `user.password`. Using either link saves a new password hash, which changes the HMAC input, so `check_token()` rejects the other link from then on. Before either is used, both are valid until `PASSWORD_RESET_TIMEOUT` passes.
  • What happens to outstanding reset links if the team lowers PASSWORD_RESET_TIMEOUT from three days to one hour?
    They follow the new value immediately. The token stores its creation time, and `check_token()` compares the current age with the setting at the moment the link is used, so links older than an hour stop working as soon as the new setting is deployed.

The link is like a sealed note stamped with the time and a fingerprint of your current lock; once you change the lock, the fingerprint no longer matches, and the desk also refuses notes stamped too long ago.

saying these in an interview costs you the question

  • Django stores each reset token in a table and deletes it after use
  • PASSWORD_RESET_TIMEOUT is measured in days
  • The token embeds an expiry date fixed when the email was sent
  • A reset link stays valid after use until the timeout passes
  • Changing the user's email has no effect on outstanding links