Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • The four password reset views and their templates
  • Token generation and link expiry
  • Console backend for development
  • SMTP settings and transactional email services

Lesson notes

1. The one feature you cannot fake

People forget passwords. In the tutorial the answer was python manage.py changepassword from a console - fine while you are the only user, useless the moment a friend signs up on your blog. Django already contains the whole reset flow; your job is to add four templates and to teach the project how to send email.

You included django.contrib.auth.urls in lesson 4.2, so these four routes are live already:

accounts/password_reset/              name="password_reset"
accounts/password_reset/done/        name="password_reset_done"
accounts/reset/<uidb64>/<token>/     name="password_reset_confirm"
accounts/reset/done/                 name="password_reset_complete"

2. The four views, in order

1. PasswordResetView form: your email address sends the mail 2. PasswordResetDoneView "check your inbox" says nothing about accounts email message /reset/<uidb64>/<token>/ valid for 3 days 3. PasswordResetConfirm checks uid and token form: new password twice valid expired or used 4. ResetCompleteView "password set" link to log in invalid link page ask for a new one the user is never logged in by this flow - and the old password hash is what makes the token single-use
ViewTemplatePurpose
PasswordResetView registration/password_reset_form.html
plus password_reset_email.html and password_reset_subject.txt
Asks for an email address, builds the token, sends the message
PasswordResetDoneView registration/password_reset_done.html "We have emailed you instructions"
PasswordResetConfirmView registration/password_reset_confirm.html Validates the link and shows the new-password form
PasswordResetCompleteView registration/password_reset_complete.html Confirmation and a link to the login page

3. The templates

<!-- templates/registration/password_reset_form.html -->
{% extends "base.html" %}

{% block content %}
  <h1>Reset your password</h1>
  <p>Enter the address you signed up with and we will send you a link.</p>
  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Send the link</button>
  </form>
{% endblock %}
<!-- templates/registration/password_reset_confirm.html -->
{% extends "base.html" %}

{% block content %}
  {% if validlink %}
    <h1>Choose a new password</h1>
    <form method="post">
      {% csrf_token %}
      {{ form.as_p }}
      <button type="submit">Save</button>
    </form>
  {% else %}
    <h1>This link no longer works</h1>
    <p>It has expired or has already been used.
      <a href="{% url 'password_reset' %}">Request a new one</a>.</p>
  {% endif %}
{% endblock %}

The email body is a template too, and it is plain text. Note that the protocol and domain come from the request through the sites framework, so you do not hard-code your host:

{# templates/registration/password_reset_email.html #}
Hello {{ user.get_username }},

Someone asked to reset the password for your account on {{ site_name }}.
If it was you, follow this link:

{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}

The link works once and expires in three days. If it was not you, ignore this
message - your password has not changed.
{# templates/registration/password_reset_subject.txt #}
Reset your password on {{ site_name }}

4. What is in the link, and why it expires

The URL carries two opaque pieces:

  • uidb64 - the user's primary key, base64 encoded. It is not a secret, only an identifier.
  • token - an HMAC produced by PasswordResetTokenGenerator from SECRET_KEY plus the user's primary key, the current password hash, the last_login timestamp, the email address and a timestamp.

Two useful properties follow directly from that recipe:

  1. The token is single-use. Setting the new password changes the password hash, so the same link stops validating. Logging in also invalidates it, because last_login is part of the input.
  2. Rotating SECRET_KEY kills every outstanding link. That is a feature, but it also means you must not regenerate the key on every deploy.
# mysite/settings.py
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 3   # seconds; the default is 3 days

Nothing here is stored in the database - there is no "reset request" table to clean up.

5. Running the flow locally with the console backend

You do not need a mail server to develop. Point Django at the console and the message is printed into runserver's output:

# mysite/settings.py (development)
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEFAULT_FROM_EMAIL = "blog@localhost"
Content-Type: text/plain; charset="utf-8"
Subject: Reset your password on example.com
From: blog@localhost
To: ada@example.com

Hello ada,
...
http://127.0.0.1:8000/accounts/reset/Mg/cf8j2p-6b3f1c0d9e.../

Copy the link into the browser and you have tested the whole path. Other useful backends:

BackendWhat it doesGood for
console.EmailBackendprints to stdoutday-to-day development
filebased.EmailBackendwrites .log files to EMAIL_FILE_PATHinspecting HTML mail
locmem.EmailBackendcollects into django.core.mail.outboxtests - it is the default there
smtp.EmailBackendtalks to a real serverstaging and production

Because tests use the locmem backend, asserting on the mail is easy:

# accounts/tests.py
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.urls import reverse


class PasswordResetTests(TestCase):
    def test_reset_sends_one_email_with_a_working_link(self):
        get_user_model().objects.create_user(
            "ada", "ada@example.com", "old-password-123"
        )
        self.client.post(reverse("password_reset"), {"email": "ada@example.com"})

        self.assertEqual(len(mail.outbox), 1)
        body = mail.outbox[0].body
        link = [w for w in body.split() if "/accounts/reset/" in w][0]
        self.assertEqual(self.client.get(link, follow=True).status_code, 200)

    def test_unknown_address_is_silent(self):
        self.client.post(reverse("password_reset"), {"email": "nobody@example.com"})
        self.assertEqual(len(mail.outbox), 0)

6. Real email in production

Never send from your personal mailbox and never put credentials in settings.py. Read them from the environment, exactly as you did for SECRET_KEY in module 1:

# mysite/settings.py (production)
import os

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ["EMAIL_HOST"]
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_HOST_USER = os.environ["EMAIL_HOST_USER"]
EMAIL_HOST_PASSWORD = os.environ["EMAIL_HOST_PASSWORD"]
EMAIL_USE_TLS = True                    # port 587
# EMAIL_USE_SSL = True                  # port 465 - never both
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "blog@example.com")
SERVER_EMAIL = DEFAULT_FROM_EMAIL       # used for error reports to ADMINS
EMAIL_TIMEOUT = 10                      # seconds, so a dead server cannot hang a request
# .env - never committed
EMAIL_HOST=smtp.example-provider.com
EMAIL_PORT=587
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=...
DEFAULT_FROM_EMAIL=Blog <hello@yourdomain.com>

Use a transactional email service - Mailgun, Postmark, Brevo, Amazon SES, SendGrid - rather than a personal Gmail account. They handle SPF, DKIM and DMARC records for your domain, which is what actually keeps your mail out of the spam folder, and they show you delivery logs. On the free PythonAnywhere plan outbound connections are limited to a whitelist, so check that your provider is on it or upgrade the account.

The reset email is built from the request host, so two more settings matter in production:

  • ALLOWED_HOSTS must contain your real domain, otherwise the request is rejected before the view runs.
  • Set SITE_ID and edit the entry in the admin under Sites, or the mail says example.com.

Smoke-test the credentials from the shell before you trust the flow:

python manage.py shell -c "from django.core.mail import send_mail; \
send_mail('Test', 'It works.', None, ['you@example.com'])"

7. Exercise

  1. Write all four reset templates plus the email body and subject, and walk through the flow with the console backend.
  2. Click the same link twice and read the "no longer works" branch of password_reset_confirm.html.
  3. Set PASSWORD_RESET_TIMEOUT to 60, wait two minutes and confirm the link expires.
  4. Request a reset for an address that does not exist and verify from the console that no mail was sent while the page looks identical.
  5. Add the two tests from section 5, then move the SMTP settings into environment variables and send one real message to yourself.

Further reading

After this lesson you will

  • Run the full password reset flow locally
  • Send real email from your Django project