Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Django  Module 6 – Authentication and authorization

Login, logout and password reset

Wire up Django's login, logout and password reset views, protect pages with login_required or LoginRequiredMiddleware, and see what each step does to a session.

  • Beginner
  • 20 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Connect Django's login, logout and password reset views to your own templates
  • Protect pages with login_required, or make every page private with LoginRequiredMiddleware
  • Explain what login() and logout() change in the session, and why logout must be a POST
  • Trace a password reset link from the email to the new password, and say when it stops working

Before you start

What you’ll learn · Before you start

What you will learn

  • Connect Django's login, logout and password reset views to your own templates
  • Protect pages with login_required, or make every page private with LoginRequiredMiddleware
  • Explain what login() and logout() change in the session, and why logout must be a POST
  • Trace a password reset link from the email to the new password, and say when it stops working

Before you start

On this page

A resident who signs in to Nivas on the tablet at the society office and walks away leaves a session that the next person at the tablet can use, and a password reset link left in an inbox opens her account for up to 3 days. Signing in, signing out and resetting a password are where a web app gives access and takes it back, and each step has a detail that decides whether it is safe.

Django ships all of it in django.contrib.auth: eight URLs and their views, to which you add templates. This lesson connects them to Nivas, follows a session through a sign-in and a sign-out, protects pages one at a time and then all at once, and traces a reset link from the email to the moment it stops working. Every run on the page went through Django 6.1.2’s test client.

Django ships the sign-in views, and you write their templates

One line in the project’s URLconf, path('accounts/', include('django.contrib.auth.urls')), adds eight URL patterns. Each is a class-based view with a name you can reverse, and each shows a template of yours under registration/, except logout while LOGOUT_REDIRECT_URL is set:

URL under accounts/, and its name What it shows, from registration/
login/, login login.html: the sign-in form
logout/, logout signs out on a POST only, then goes to LOGOUT_REDIRECT_URL (logged_out.html when it is not set)
password_change/, password_change password_change_form.html, for a signed-in user
password_change/done/, password_change_done password_change_done.html
password_reset/, password_reset password_reset_form.html: asks for the email address
password_reset/done/, password_reset_done password_reset_done.html: the same page for every address
reset/<uidb64>/<token>/, password_reset_confirm password_reset_confirm.html: the new password
reset/done/, password_reset_complete password_reset_complete.html

The views are Django’s; the pages are yours, so they match the rest of the site. Here are the Nivas URLconf and the sign-in template. The form is an AuthenticationForm with a username and a password field, and the hidden next field carries the page the visitor asked for:

The URLconf and the sign-in template

config/urls.py · nivas/config/urls.py

from django.urls import include, path

from notices import views

urlpatterns = [
    path('', views.home, name='home'),
    path('language/', views.choose_language, name='choose-language'),
    path('rules/', views.society_rules, name='rules'),
    path('accounts/', include('django.contrib.auth.urls')),
    path('notices/', views.notice_list, name='notices'),
    path('dues/', views.dues, name='dues'),
]

templates/registration/login.html · nivas/templates/registration/login.html

{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
  <h1>Sign in</h1>
  {{ form.non_field_errors }}
  <form method="post" action="{% url 'login' %}">
    {% csrf_token %}
    {{ form.username.as_field_group }}
    {{ form.password.as_field_group }}
    {# LoginView reads ?next= and checks that it stays on this site before it redirects there. #}
    <input type="hidden" name="next" value="{{ next }}">
    <button type="submit">Sign in</button>
  </form>
  <p><a href="{% url 'password_reset' %}">Forgotten your password?</a></p>
{% endblock %}

notices/views.py · nivas/notices/views.py

from django.contrib.auth.decorators import login_not_required, login_required
from django.shortcuts import redirect, render
from django.views.decorators.http import require_POST

NOTICES = ['Water tank cleaning on Saturday, 10:00 to 13:00', 'Lift B is back in service']


@login_not_required  # public, also when LoginRequiredMiddleware makes every other page private
def home(request):
    return render(request, 'home.html')


@login_not_required
@require_POST
def choose_language(request):
    """A visitor's choice, kept in their session before they sign in (an anonymous session)."""
    request.session['language'] = request.POST.get('language', 'en')
    return redirect('home')


@login_not_required
def society_rules(request):
    return render(request, 'rules.html')


@login_required  # anonymous visitors go to settings.LOGIN_URL, with ?next= this page
def notice_list(request):
    return render(request, 'notices/list.html', {'notices': NOTICES})


def dues(request):  # no decorator: public, unless LoginRequiredMiddleware is installed
    return render(request, 'notices/dues.html', {'due': 'B-1203: 2,450.00 due this month'})

The settings add two lines: LOGIN_REDIRECT_URL = 'home', where a sign-in goes when the request named no page, and LOGOUT_REDIRECT_URL = 'home'. LOGIN_URL keeps its default, /accounts/login/. This program signs in through the test client: django.test.Client: sends requests to a Django project from Python code, through the same handler, middleware and views a server would reach, without a network or a running server. More in “How Django handles a request”, as a browser would:

Signing in through LoginView Python · nivas/check_login.py
"""Sign in to Nivas through Django's LoginView: a protected page, the login page, a wrong password, the right one,
and a ?next= that points to another site.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.auth.models import User  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')


def show(response):
    where = f', Location: {response["Location"]}' if response.has_header('Location') else ''
    print(f'   {response.status_code} {response.reason_phrase}{where}')


browser = Client()
print('1. GET /notices/ without signing in')
show(browser.get('/notices/'))

print('2. GET /accounts/login/?next=/notices/')
response = browser.get('/accounts/login/?next=/notices/')
show(response)
print(f'   template: {response.templates[0].name}')
print(f'   Cache-Control: {response["Cache-Control"]}')

print('3. POST a wrong password')
response = browser.post('/accounts/login/', {'username': 'asha', 'password': 'wrong', 'next': '/notices/'})
show(response)
print(f'   {response.context["form"].non_field_errors()[0]}')

print('4. POST the right password, next=/notices/')
show(browser.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again', 'next': '/notices/'}))
response = browser.get('/notices/')
print(f'   then GET /notices/: {response.status_code}, "{response.context["user"].get_username()}" is signed in')

print('5. POST the right password, next=https://example.com/')
other = Client()
show(other.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again', 'next': 'https://example.com/'}))

Output

1. GET /notices/ without signing in
   302 Found, Location: /accounts/login/?next=/notices/
2. GET /accounts/login/?next=/notices/
   200 OK
   template: registration/login.html
   Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private
3. POST a wrong password
   200 OK
   Please enter a correct username and password. Note that both fields may be case-sensitive.
4. POST the right password, next=/notices/
   302 Found, Location: /notices/
   then GET /notices/: 200, "asha" is signed in
5. POST the right password, next=https://example.com/
   302 Found, Location: /

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_login.py

Read the five steps. The notices page sent the visitor to the login page with next=/notices/, the page she asked for. The login page came back with Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private, because LoginView is wrapped in never_cache; it is also wrapped in csrf_protect and sensitive_post_parameters, which keeps the password out of Django’s error reports. A wrong password got one message about the username and the password together, so the page never says which of the two was wrong. The right password led to /notices/, and the last sign-in, whose next named another site, went to /, the LOGIN_REDIRECT_URL: LoginView follows next only when it stays on this site.

Checkpoint

Check: The sign-in views and their templates

  1. Question 1 of 2 A resident types the right username and a wrong password into the Nivas sign-in form. What does LoginView answer?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 200 with the form again and one message about the username and the password together

    AuthenticationForm calls authenticate(); when no backend returns a user, the form gets one non-field error and LoginView renders it with status 200. The message is the same whether the username or the password was wrong.

  2. Question 2 of 2 A link to Nivas reads /accounts/login/?next=https://example.com/. The resident signs in with the right password. Where does the browser go?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: To LOGIN_REDIRECT_URL, here /, because next does not stay on this site

    next is part of the request, so anyone who writes the link chooses it. LoginView keeps it only for a path or address of the same site and falls back to LOGIN_REDIRECT_URL for anything else.

login() gives the session a new key, and logout() empties it

A session: Data Django keeps on the server for one browser, found again on each request through the random key in the browser's sessionid cookie. is data Django keeps on the server for one browser, found again on each request through the random key in the sessionid cookie. Signing in writes the user into that data, so whoever holds the key holds the account. That is why the key must change at the moment of signing in: an attacker who got a victim to use a key the attacker already knew, which is called session fixation: An attack in which a victim is made to sign in with a session key the attacker already knows; giving the session a new key at sign-in, as login() does, defeats it., would otherwise be signed in too. OWASP’s session management guidance makes renewing the key after every change of privilege mandatory, and Django’s login() does it for you. Step through a visitor who chooses a language, signs in and signs out:

Interactive · message by message What signing in and out does to the session Step through a visitor who chooses a language, signs in, tries a logout link and signs out with the form.
What signing in and out does to the session, step 8 of 8. Messages so far: Nivas to Session table: new row: language hi; Nivas to Asha's browser: 302 to login?next=/notices/; Asha's browser to Nivas: POST /accounts/login/; Nivas to Session table: key B; row A deleted; Nivas to Asha's browser: 302 to /notices/; Asha's browser to Nivas: GET /notices/ with key B; Nivas to Asha's browser: 405 Method Not Allowed; Nivas to Session table: row B deleted.Asha's browsersessionid: emptyNivasDjango and its middlewareSession tabledjango_sessionnew row: language hi302 to login?next=/notices/POST /accounts/login/key B; row A deleted302 to /notices/GET /notices/ with key B405 Method Not Allowedrow B deletednext GET /notices/ is a 302

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. An anonymous session. Before signing in, the visitor chooses Hindi. The view stores it in the session, so SessionMiddleware saves a new row and sends its key, 32 characters, in the sessionid cookie.
  2. A private page. She opens /notices/. The view has @login_required and nobody is signed in, so the answer is a redirect to the login page, with the page she wanted in next.
  3. The password is checked. She posts her username and password. authenticate() asks each backend in AUTHENTICATION_BACKENDS; ModelBackend finds asha and checks the password against its stored hash.
  4. login() changes the key. login() gives the session a new key and deletes the row of key A, keeping its data: the language is still there. Whoever knew key A, even someone who planted it, now holds nothing.
  5. The user goes into the session. The new session holds the user's id, the backend that checked her and a hash made from her password, and the CSRF token is changed too. The answer redirects to next.
  6. Every later request. From now on AuthenticationMiddleware reads the user from the session on each request, so request.user is asha and /notices/ answers 200.
  7. A logout link does nothing. A GET to /accounts/logout/ answers 405 Method Not Allowed, with Allow: POST, OPTIONS. She is still signed in, so a link or an image on another page cannot sign her out.
  8. The logout form. The header form posts to /accounts/logout/ with its CSRF token. logout() flushes the session: the row of key B is deleted and the cookie is emptied.

The run behind the figure checks each claim against the session table:

The session before and after signing in and out Python · nivas/check_sessions.py
"""What signing in and out does to a visitor's session: the key, the data in it, the CSRF token and the row in the
session table. Keys and tokens are random, so it prints their lengths and whether they changed.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import logging
import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.contrib.auth.models import User  # noqa: E402
from django.contrib.sessions.models import Session  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')
logging.getLogger('django.request').setLevel(logging.ERROR)  # the 405 below is expected

browser = Client()


def cookie(name):
    morsel = browser.cookies.get(name)
    return morsel.value if morsel else ''


def language():
    key = cookie('sessionid')
    row = Session.objects.filter(session_key=key).first()
    return row.get_decoded().get('language', 'none') if row else 'none'


print('1. Before signing in, a visitor chooses Hindi')
browser.post('/language/', {'language': 'hi'})
browser.get('/accounts/login/')  # the login page sets the csrftoken cookie
key_before, csrf_before = cookie('sessionid'), cookie('csrftoken')
print(f'   session key: {len(key_before)} characters; language in the session: {language()}')

print('2. Asha signs in')
response = browser.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again'})
print(f'   {response.status_code} {response.reason_phrase}, Location: {response["Location"]}')
print(f'   session key changed: {cookie("sessionid") != key_before}; language kept: {language()}')
print(f'   CSRF token changed: {cookie("csrftoken") != csrf_before}')
print(f'   the old key is still in the session table: {Session.objects.filter(session_key=key_before).exists()}')

print('3. GET /accounts/logout/, as a link would')
response = browser.get('/accounts/logout/')
print(f'   {response.status_code} {response.reason_phrase}, Allow: {response["Allow"]}')
print(f'   still signed in: {browser.get("/notices/").status_code == 200}')

print('4. POST /accounts/logout/, from the form in the page header')
key_signed_in = cookie('sessionid')
response = browser.post('/accounts/logout/')
print(f'   {response.status_code} {response.reason_phrase}, Location: {response["Location"]}')
print(f'   session cookie now: "{cookie("sessionid")}"; its row is gone: {not Session.objects.filter(session_key=key_signed_in).exists()}')
print(f'   GET /notices/ now: {browser.get("/notices/").status_code}')

Output

1. Before signing in, a visitor chooses Hindi
   session key: 32 characters; language in the session: hi
2. Asha signs in
   302 Found, Location: /
   session key changed: True; language kept: hi
   CSRF token changed: True
   the old key is still in the session table: False
3. GET /accounts/logout/, as a link would
   405 Method Not Allowed, Allow: POST, OPTIONS
   still signed in: True
4. POST /accounts/logout/, from the form in the page header
   302 Found, Location: /
   session cookie now: ""; its row is gone: True
   GET /notices/ now: 302

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_sessions.py

At the sign-in the key changed, the row of the old key left the table, and the language chosen before the sign-in was kept, as the documentation says data of the anonymous session is. The CSRF token changed too. The new session holds the user’s id, the backend that checked her and a hash made from her password hash. Django compares that hash on every request, so when the password changes, every other session of the account stops matching and is signed out.

Signing out is a POST. Since Django 5.0, LogoutView answers a GET with 405 Method Not Allowed, so a link or an image that another page loads cannot sign anyone out, and the template signs out with a small form that carries the CSRF token.

A sign-out link

<a href="{% url 'logout' %}">Sign out</a> gets a 405 on Django 5.0 and later, and the resident stays signed in. Sign out with <form method="post" action="{% url 'logout' %}">, {% csrf_token %} and a button, as the header of base.html does.

logout() then flushes the session: the run found the row deleted, the cookie empty and /notices/ redirecting again.

Key idea

login() replaces the session key and keeps the data, and logout() deletes the session on the server, so a key learned before the sign-in, or left behind after the sign-out, opens nothing.

Checkpoint

Check: What login() and logout() do to the session

  1. Question 1 of 2 Someone copied the sessionid cookie from a visitor before she signed in. She then signs in. What can the copied key do now?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing. login() gave the session a new key and deleted the row of the old one

    login() cycles the key of an anonymous session: a new key, the same data, the old row deleted. So a key known before the sign-in is worthless after it, which is why OWASP makes renewing the key at sign-in mandatory.

  2. Question 2 of 2 A page of Nivas has <a href="/accounts/logout/">Sign out</a>. A signed-in resident clicks it. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 405 Method Not Allowed, and the resident is still signed in

    A sign-out changes state, so it is a POST from a form with the CSRF token. A GET could come from any page that makes the browser load the address.

Protect pages one at a time, or make every page private

@login_required on a view sends a visitor who has not signed in to settings.LOGIN_URL, with the path they asked for in next. It protects exactly the views that carry it, so a view somebody forgot to decorate is public. Nivas has one: dues, the page of maintenance dues, has no decorator.

LoginRequiredMiddleware, new in Django 5.1, turns the rule around: with it in MIDDLEWARE, every view needs a signed-in visitor unless it is marked @login_not_required. It goes after AuthenticationMiddleware, whose user it reads, and Django’s system check reports any other order as error auth.E013. The sign-in and reset views of django.contrib.auth already carry the mark, so the login page never redirects to itself. Step through the decision it makes for each request:

Interactive · stage by stage How LoginRequiredMiddleware decides one request Step through the anonymous requests for /rules/ and /dues/, then a signed-in request for /dues/.
How LoginRequiredMiddleware decides one request, step 5 of 5. Showing The URL finds its view, Marked login_not_required?, Is the visitor signed in?, 302 to LOGIN_URL (/accounts/login/?next=/dues/), The view runs (dues: 200 for asha).yes: publicyes: signed inThe URL finds its viewbefore the view runsMarked login_not_required?an attribute of the viewIs the visitor signed in?request.user302 to LOGIN_URL/accounts/login/?next=/dues/The view runsdues: 200 for asha/dues/

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The URL is resolved. The middleware works in process_view(), after the URLconf has found the view and before it runs, so it can read the view itself. An address that matches no view is a 404 as before.
  2. /rules/ is public. society_rules has @login_not_required, which sets login_required = False on the view. The middleware lets it through without asking who the visitor is.
  3. /dues/ is not marked. dues has no decorator at all. Without the middleware it answered 200 to anyone; now an unmarked view counts as private, so the middleware asks whether the visitor is signed in.
  4. Nobody is signed in. request.user is AnonymousUser, so the middleware answers 302 to /accounts/login/?next=/dues/, as @login_required would, and the view never runs.
  5. After signing in. Once Asha has signed in, the same request reaches the same question with request.user set to asha, and the view runs: a page nobody marked is private, never public by mistake.

This program asks for six pages as a visitor who has not signed in, first with the project’s middleware and then with LoginRequiredMiddleware added:

Six pages, with and without LoginRequiredMiddleware Python · nivas/private_by_default.py
"""Every page of Nivas asked for by a visitor who has not signed in, first with the project's MIDDLEWARE, then with
LoginRequiredMiddleware added after AuthenticationMiddleware. home, choose_language and society_rules are marked
@login_not_required; notice_list has @login_required; dues has no decorator at all.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import os

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.test import Client, override_settings  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})

PAGES = ['/', '/rules/', '/notices/', '/dues/', '/accounts/login/', '/accounts/password_reset/']
PRIVATE = [*settings.MIDDLEWARE, 'django.contrib.auth.middleware.LoginRequiredMiddleware']


def answer(client, path):
    response = client.get(path)
    if response.status_code == 302:
        return f'302 to {response["Location"]}'
    return f'{response.status_code} {response.reason_phrase}'


print('Without LoginRequiredMiddleware:')
visitor = Client()
for path in PAGES:
    print(f'   {path:27} {answer(visitor, path)}')

print('With LoginRequiredMiddleware:')
with override_settings(MIDDLEWARE=PRIVATE):
    visitor = Client()  # a new client loads the new middleware
    for path in PAGES:
        print(f'   {path:27} {answer(visitor, path)}')

Output

Without LoginRequiredMiddleware:
   /                           200 OK
   /rules/                     200 OK
   /notices/                   302 to /accounts/login/?next=/notices/
   /dues/                      200 OK
   /accounts/login/            200 OK
   /accounts/password_reset/   200 OK
With LoginRequiredMiddleware:
   /                           200 OK
   /rules/                     200 OK
   /notices/                   302 to /accounts/login/?next=/notices/
   /dues/                      302 to /accounts/login/?next=/dues/
   /accounts/login/            200 OK
   /accounts/password_reset/   200 OK

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 private_by_default.py

Only one line changed. Without the middleware /dues/ answered 200 to anyone; with it, the same view redirected to the login page with next=/dues/. The public pages, the notices page and Django’s own sign-in pages answered exactly as before. With the middleware a forgotten mark fails closed: a page nobody marked asks for a sign-in, and a page that should be public answers with a redirect that someone notices at once.

Check next before you redirect to it

LoginView follows next only after url_has_allowed_host_and_scheme() has confirmed that it stays on this site, which is why the run’s sign-in with next=https://example.com/ went to /. A view of your own that redirects to request.GET['next'] unchecked is an open redirect: A page that redirects to any address taken from the request, so that a link to a trusted site can send its visitors on to another site.: a link to your site that sends people on to any other. Check it with the same function. A rule of your own, a path that starts with one /, must also refuse a backslash in second place and every tab or line break, which browsers drop before they read the address; the practice below writes that rule.

Checkpoint

Check: login_required and LoginRequiredMiddleware

  1. Question 1 of 2 Nivas uses @login_required on its private views, and nobody decorated dues. A visitor who has not signed in opens /dues/. What does the recorded run show?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 200 OK, the dues page, until LoginRequiredMiddleware is added

    Decorators fail open: a forgotten one leaves the page public. LoginRequiredMiddleware fails closed: a page nobody marked asks for a sign-in.

  2. Question 2 of 2 A project adds LoginRequiredMiddleware to MIDDLEWARE before AuthenticationMiddleware. What does Django say, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The system check reports error auth.E013: the middleware reads the user that AuthenticationMiddleware sets, so it belongs after it

    LoginRequiredMiddleware decides with request.user, which AuthenticationMiddleware puts on the request. The documentation places it after AuthenticationMiddleware for that reason, and the system check reports any other order as error auth.E013.

A forgotten password is reset by email. PasswordResetView takes an address and emails a link to every active user who has that address and a usable password; the link leads to a form for the new password. Step through the recorded run, from the form to the same link opened a second time:

Interactive · message by message A password reset, from the form to a used link Step through the two reset requests, the email, the link and the new password, then the same link again.
A password reset, from the form to a used link, step 7 of 7. Messages so far: Nivas to Asha's inbox: reset link for asha; Nivas to Asha's browser: 302 to password_reset/done/; Nivas to Asha's browser: 302 to MQ/set-password/; Asha's browser to Nivas: POST new password twice; Nivas to Asha's browser: validlink: False.Asha's browserNivasthe reset viewsAsha's inbox/accounts/reset/MQ/<token>/reset link for asha302 to password_reset/done/302 to MQ/set-password/POST new password twicevalidlink: Falseexpires after 3 days

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. A known address. She posts asha@example.com to /accounts/password_reset/. PasswordResetView looks for active users with that address and a usable password, finds asha and sends the email.
  2. The same answer for everyone. The answer is 302 to /accounts/password_reset/done/. A post of nobody@example.com gets the same 302 and the same page, and no email, so the page tells nobody which addresses have accounts.
  3. The link. The email holds /accounts/reset/MQ/<token>/. MQ is the user id 1 in base64; the token is a timestamp and an HMAC of her id, password hash, last login and email, made with SECRET_KEY.
  4. The token leaves the address. She opens the link. The view checks the token, keeps it in her session and redirects to /accounts/reset/MQ/set-password/, so the token is no longer in the address bar, the history or a Referer header.
  5. A new password. She posts the new password twice. The form runs the password validators, set_password() stores a new hash, and the answer is 302 to /accounts/reset/done/.
  6. The link works once. Opening the same link again shows "This link no longer works": the HMAC was made from the old password hash, so it no longer matches. A new sign-in would have killed it too, through last_login.
  7. Three days at most. An unused link dies on its own once its timestamp is older than PASSWORD_RESET_TIMEOUT, 259,200 seconds. The recorded token was valid at exactly 259,200 seconds and refused at 259,201.

The run reads the email from mail.outbox, where the test environment keeps every message it sends:

A reset, from the form to a link that no longer works Python · nivas/check_reset.py
"""The password reset flow of django.contrib.auth.urls, read from the email Django sends (mail.outbox), then the
clock of the reset token. Tokens are random, so the link is printed with <token> in their place.

Run it next to manage.py. It makes a test database in memory, as manage.py test does."""

import datetime
import os
import re

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')

import django  # noqa: E402

django.setup()

from django.conf import settings  # noqa: E402
from django.contrib.auth.models import User  # noqa: E402
from django.contrib.auth.tokens import PasswordResetTokenGenerator  # noqa: E402
from django.core import mail  # noqa: E402
from django.test import Client  # noqa: E402
from django.test.utils import setup_databases, setup_test_environment  # noqa: E402

setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')


def show(response):
    where = f', Location: {response["Location"]}' if response.has_header('Location') else ''
    print(f'   {response.status_code} {response.reason_phrase}{where}')


browser = Client()
print('1. A reset for asha@example.com, then one for nobody@example.com')
show(browser.post('/accounts/password_reset/', {'email': 'asha@example.com'}))
show(browser.post('/accounts/password_reset/', {'email': 'nobody@example.com'}))
print(f'   emails sent: {len(mail.outbox)}, to {mail.outbox[0].to[0]}; subject: {mail.outbox[0].subject}')
link = re.search(r'http://testserver(/accounts/reset/\S+/)', mail.outbox[0].body).group(1)
uid, token = link.split('/')[3:5]
print(f'   link: {link.replace(token, "<token>")} (uid {uid} is the user id 1 in base64)')

print('2. Asha opens the link')
show(browser.get(link))
print('3. She chooses a new password')
response = browser.get('/accounts/reset/MQ/set-password/')
print(f'   GET the form: {response.status_code}, link valid: {response.context["validlink"]}')
show(browser.post('/accounts/reset/MQ/set-password/', {'new_password1': 'quiet-hours-22-06', 'new_password2': 'quiet-hours-22-06'}))
print('4. The same link again')
response = Client().get(link, follow=True)
print(f'   {response.status_code}, link valid: {response.context["validlink"]}')
print('5. Signing in')
print(f'   old password: {Client().login(username="asha", password="lift-B-works-again")}; new password: {Client().login(username="asha", password="quiet-hours-22-06")}')


class Clock(PasswordResetTokenGenerator):
    """The token generator with a clock this program sets (Django's _now() is there to be replaced in tests)."""

    now = datetime.datetime(2026, 1, 1, 9, 0)

    def _now(self):
        return self.now


print(f'6. The token clock: PASSWORD_RESET_TIMEOUT = {settings.PASSWORD_RESET_TIMEOUT} seconds')
user = User.objects.get(username='asha')
tokens = Clock()
token = tokens.make_token(user)
start = Clock.now
for seconds in [0, 259_200, 259_201]:
    Clock.now = start + datetime.timedelta(seconds=seconds)
    print(f'   {seconds:>7,} seconds later: valid {tokens.check_token(user, token)}')
Clock.now = start
user.last_login = datetime.datetime(2026, 1, 1, 9, 5, tzinfo=datetime.UTC)
print(f'   after Asha signs in again (last_login changes): valid {tokens.check_token(user, token)}')

Output

1. A reset for asha@example.com, then one for nobody@example.com
   302 Found, Location: /accounts/password_reset/done/
   302 Found, Location: /accounts/password_reset/done/
   emails sent: 1, to asha@example.com; subject: Password reset on testserver
   link: /accounts/reset/MQ/<token>/ (uid MQ is the user id 1 in base64)
2. Asha opens the link
   302 Found, Location: /accounts/reset/MQ/set-password/
3. She chooses a new password
   GET the form: 200, link valid: True
   302 Found, Location: /accounts/reset/done/
4. The same link again
   200, link valid: False
5. Signing in
   old password: False; new password: True
6. The token clock: PASSWORD_RESET_TIMEOUT = 259200 seconds
         0 seconds later: valid True
   259,200 seconds later: valid True
   259,201 seconds later: valid False
   after Asha signs in again (last_login changes): valid False

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_reset.py

The link names your domain

Without the sites framework, the link’s domain comes from the request’s Host header, which Django checks against ALLOWED_HOSTS. In production keep that list to your own domains, so a reset email can only ever point there.

Four details keep the flow safe. Both addresses got the same 302 and the same page, and only the known one got an email, so the form tells nobody which addresses have accounts. The documentation adds a caution: sending an email takes time, so a request for a known address may be measurably slower. The token in the link moved into the session at the first visit, and the browser went on to set-password/, so the token is no longer in the address bar or in a Referer header sent to another site. The token is an HMAC of the user’s id, password hash, last sign-in time, email and a timestamp, made with SECRET_KEY; a new password changed the hash it was made from, so the second visit showed “This link no longer works”. And with no use at all, the link expires: PASSWORD_RESET_TIMEOUT is 259,200 seconds, three days, and the token was still valid at exactly that age and refused one second later.

Key idea

A reset link is a signed, three-day token that dies as soon as the password, the email or the last sign-in changes, and the form gives the same answer for every address.

Checkpoint

Check: The password reset link

  1. Question 1 of 2 A visitor asks for a reset link for nobody@example.com, an address no account uses. What does PasswordResetView do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It sends no email and answers with the same 302 to password_reset/done/ as for a known address

    The documentation says no email is sent when the address is unknown, the user is inactive or the password is unusable, and that the visitor sees no error either, so the form leaks nothing.

  2. Question 2 of 2 Asha uses a reset link and sets a new password. An hour later she opens the same link again. Why does Django refuse it?
    Show the answer: Question 2 of 2

    Answer: The token is an HMAC of her password hash among other things, and the hash changed with the new password

    The token is made from her id, password hash, last sign-in time, email and a timestamp. Changing any of the first four makes old links stop matching; the timestamp makes every link expire after the timeout.

HTTP Status Codes Reference Look up 302, 403 and 405, and what a browser does with each.

Practise: the sign-in gate of Nivas

Two small functions do the decisions of this lesson in plain Python, with sample tests that run in your browser: the gate that makes Nivas private by default, and the check that keeps next on this site.

Code lab · 2 steps

Build the sign-in gate of Nivas

Decide each request the way LoginRequiredMiddleware does, then send a resident back after signing in only to a page of this site.

  1. Decide a request the way LoginRequiredMiddleware does To do
  2. Send a resident back only to a page of this site To do

Exercise · Easy · Python

Decide a request the way LoginRequiredMiddleware does

Nivas becomes private by default: every page needs a signed-in resident, except the pages the society marks public. Write gate(full_path, signed_in, public), which decides one request the way LoginRequiredMiddleware and redirect_to_login() do. It returns None when the page may be served, or the address to redirect to.

  • full_path is the path with its query string, as request.get_full_path() gives it, such as '/notices/?page=2'.
  • public is a set of paths, such as {'/', '/rules/'}. Only the path decides, never the query string, and a path is public only when it is exactly one of them.
  • A signed-in visitor is served every page.
  • The login page, LOGIN_URL, is always served, whatever public holds, so a visitor is never sent from the login page to the login page.
  • Any other request from a visitor who has not signed in goes to LOGIN_URL with the whole full_path in next, encoded as Django encodes it: urlencode({'next': full_path}, safe='/').

gate('/notices/?page=2', False, {'/', '/rules/'}) returns '/accounts/login/?next=/notices/%3Fpage%3D2'.

Starter code · gate.py

from urllib.parse import urlencode  # noqa: F401  (you will need it for the redirect)

LOGIN_URL = '/accounts/login/'


def gate(full_path, signed_in, public):
    """Return None to serve the page, or the login URL with ?next= to redirect to."""
    return None
The sample tests · test_gate.py
from gate import gate

PUBLIC = {'/', '/rules/'}


def test_public_pages_are_served_to_everyone():
    assert gate('/', False, PUBLIC) is None
    assert gate('/rules/', False, PUBLIC) is None


def test_a_private_page_sends_the_visitor_to_sign_in():
    assert gate('/dues/', False, PUBLIC) == '/accounts/login/?next=/dues/'


def test_the_query_string_goes_into_next_encoded():
    assert gate('/notices/?page=2', False, PUBLIC) == '/accounts/login/?next=/notices/%3Fpage%3D2'
    assert gate('/notices/?page=2&tower=B', False, PUBLIC) == '/accounts/login/?next=/notices/%3Fpage%3D2%26tower%3DB'


def test_a_signed_in_resident_is_served_every_page():
    for path in ['/', '/dues/', '/notices/?page=2']:
        assert gate(path, True, PUBLIC) is None, path


def test_the_login_page_is_always_served():
    assert gate('/accounts/login/', False, set()) is None
    assert gate('/accounts/login/?next=/dues/', False, PUBLIC) is None


def test_only_the_path_decides_whether_a_page_is_public():
    assert gate('/rules/?lang=hi', False, PUBLIC) is None


def test_a_path_must_match_exactly_to_be_public():
    assert gate('/rules-archive/', False, PUBLIC) == '/accounts/login/?next=/rules-archive/'
    assert gate('/rules', False, PUBLIC) == '/accounts/login/?next=/rules'


def test_nothing_is_public_unless_listed():
    assert gate('/', False, set()) == '/accounts/login/?next=/'
A hint

Split the path from the query string first: full_path.split('?', 1)[0] is the part that decides. Then check the three cases that serve the page, in any order, and build the redirect only when none of them applies.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Easy · Python

Send a resident back only to a page of this site

After a sign-in, Nivas sends the resident to the page in ?next=. A redirect to whatever next says would let any link that carries a crafted next send residents from your sign-in page to another site, which is why LoginView checks it first. Write safe_next(next_url, default='/'), the stricter rule this project keeps for its own views: return next_url only when it is a path on this site, and default otherwise.

A path on this site starts with exactly one /:

  • '/notices/' and '/notices/?page=2' are paths of this site.
  • 'https://example.com/' names another site, and so does '//example.com/', which browsers read as https://example.com/ on an HTTPS page.
  • Browsers treat a backslash in an address like a slash, so '/\\example.com/', a slash and then one backslash (Python writes a backslash as '\\'), is read as '//example.com/'.
  • Browsers also remove every tab and line break from an address before they read it, so '/\t/example.com/', a slash, a tab and then /example.com/, is read as '//example.com/' too. A next that holds a tab ('\t'), a line feed ('\n') or a carriage return ('\r') anywhere gives default.
  • An empty next, or one that does not start with / ('notices/'), gives default.

safe_next('/notices/?page=2') returns '/notices/?page=2', and safe_next('//example.com/') returns '/'.

Starter code · next_url.py

def safe_next(next_url, default='/'):
    """Return next_url if it is a path on this site, else default."""
    return next_url
The sample tests · test_next_url.py
from next_url import safe_next


def test_paths_of_this_site_are_kept():
    assert safe_next('/notices/') == '/notices/'
    assert safe_next('/notices/?page=2') == '/notices/?page=2'
    assert safe_next('/') == '/'


def test_an_address_with_a_scheme_gives_the_default():
    assert safe_next('https://example.com/') == '/'
    assert safe_next('http://example.com/notices/') == '/'


def test_two_slashes_name_another_site():
    assert safe_next('//example.com/') == '/'


def test_a_backslash_counts_as_a_slash():
    assert safe_next('/\\example.com/') == '/'


def test_tabs_and_line_breaks_give_the_default():
    assert safe_next('/\t/example.com/') == '/'
    assert safe_next('/\n/example.com/') == '/'
    assert safe_next('/\r\n/example.com/') == '/'
    assert safe_next('/notices/\t') == '/'


def test_an_empty_or_relative_next_gives_the_default():
    assert safe_next('') == '/'
    assert safe_next('notices/') == '/'


def test_the_default_can_be_chosen():
    assert safe_next('//example.com/', default='/notices/') == '/notices/'
    assert safe_next('/dues/', default='/notices/') == '/dues/'
A hint

Refuse first any next_url that holds '\t', '\n' or '\r'. Then look at the first two characters only: the first must be '/', and the second must be neither '/' nor a backslash, which Python writes as '\\'. next_url[1:2] is the second character, or '' when there is none, so it never raises an error on a string of one character.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then check the four ideas of this lesson, from the sign-in views to the reset link. The questions start where your answers have been weakest so far, and each choice explains itself:

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What does path("accounts/", include("django.contrib.auth.urls")) add to a project?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Eight URL patterns for signing in and out and for changing and resetting passwords, with their views

    auth.urls is a ready URLconf: eight named patterns that point at Django's class-based auth views. Each view renders a template of yours under registration/.

  2. Question 2 of 12 The login template leaves out <input type="hidden" name="next" value="{{ next }}">. A visitor who was sent to /accounts/login/?next=/notices/ signs in. Where does she land?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: On LOGIN_REDIRECT_URL, because the POST no longer carries next

    next travels from the GET to the POST only through the form, which is why the template carries it in a hidden field.

  3. Question 3 of 12 Why does LoginView answer with Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: It is wrapped in never_cache, so no browser or shared cache keeps a copy of the sign-in page and its CSRF token

    LoginView is wrapped in never_cache, csrf_protect and sensitive_post_parameters: no caching of the page, a CSRF check on the POST, and the password kept out of error reports.

  4. Question 4 of 12 A visitor chose Hindi before signing in, and the choice was stored in her session. What happens to it when she signs in?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: It is kept, in a session with a new key

    cycle_key() makes a new key for the same data and deletes the old row, so the visitor keeps what she chose and the old key is worthless.

  5. Question 5 of 12 After logout(), someone replays the sessionid cookie the resident had while she was signed in. What do they get?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: An anonymous visitor's view, because logout() deleted that session on the server

    Signing out must end the session on the server, not only in the browser. logout() flushes it: the data and its row are deleted, so an old copy of the cookie opens nothing.

  6. Question 6 of 12 Why does Django store a hash made from the password hash in every signed-in session?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: So that changing the password signs out the account's other sessions, whose stored hash no longer matches

    The session auth hash is an HMAC of the password hash. A password change makes every older session's hash stale, and Django signs those sessions out on their next request.

  7. Question 7 of 12 With LoginRequiredMiddleware installed, how do you make the society rules page public?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: Decorate its view with @login_not_required

    The middleware reads one attribute of the view it is about to run. @login_not_required sets it, so the view is served to everyone.

  8. Question 8 of 12 A visitor who has not signed in opens /notices/?page=2. Where does @login_required send her?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: /accounts/login/?next=/notices/%3Fpage%3D2

    redirect_to_login() puts request.get_full_path() into next and encodes it with urlencode(safe='/'), so the ? becomes %3F and the = becomes %3D.

  9. Question 9 of 12 A view of your own ends with return redirect(request.GET.get("next", "/")) after saving a form. What is wrong?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: It is an open redirect, so a link to Nivas can send residents on to any site; check next first

    LoginView checks next before following it; your own views must do the same, or keep a fixed destination.

  10. Question 10 of 12 How many seconds is a reset link valid by default, the value of PASSWORD_RESET_TIMEOUT?

    Type a number.

    Show the answer: Question 10 of 12

    Answer: 259200 seconds

    60 × 60 × 24 × 3 = 259,200 seconds, three days. The recorded token was valid at exactly 259,200 seconds and refused at 259,201.

  11. Question 11 of 12 Asha asks for a reset link, then remembers her password and signs in before opening the email. What happens when she opens the link later?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: It no longer works, because the token was made from her last sign-in time, which changed

    The token hashes the user's id, password hash, last sign-in time, email and its timestamp. Any change to the first four, such as a new sign-in, makes old links stop matching.

  12. Question 12 of 12 Why does the reset view redirect from /accounts/reset/MQ/<token>/ to /accounts/reset/MQ/set-password/?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: It keeps the token in the session and takes it out of the address, so it cannot leak through the history or a Referer header

    PasswordResetConfirmView stores the checked token in the session under its own key and redirects to the same URL with set-password in place of the token.

Interview questions

Warm-up (fresher): Why does Django’s logout need a POST request?

Since Django 5.0, LogoutView signs out only on POST and answers a GET with 405 Method Not Allowed. A GET must not change anything, and a logout that worked on GET could be set off by any page that makes the browser load the address, such as an image or a link, with no CSRF token to stop it. As a POST, the logout comes from a form of the site itself, carrying the CSRF token, so only the resident’s own click signs them out. In a template that means a small form with {% csrf_token %} and a button, not a link.

Two questions of this module’s bank start from these flows: a stolen phone that is still signed in, and a reset page that would like to tell people which addresses have no account.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to senior

    A resident's phone was stolen while she was signed in to Nivas. From her laptop she changes her password. Is the stolen phone still signed in? Explain what Django does, what it leaves to you, and what you would add.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Support asks for the password reset page to say "We have no account with that email", to cut down on tickets. What do you tell them, and what do you build instead?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • include('django.contrib.auth.urls') adds the sign-in, sign-out, password change and password reset views; you write their templates under registration/ and set LOGIN_REDIRECT_URL and LOGOUT_REDIRECT_URL.
  • login() gives the session a new key and keeps its data, which defeats session fixation, and logout() deletes the session on the server; logout is a POST since Django 5.0, so sign out with a form.
  • @login_required protects one view; LoginRequiredMiddleware, after AuthenticationMiddleware, makes every view private unless it is marked @login_not_required, so a forgotten page fails closed.
  • LoginView follows next only to a page of the same site; a redirect of your own to next must be checked the same way.
  • A reset link gives the same answer for every address, moves its token out of the URL, works once and expires after PASSWORD_RESET_TIMEOUT, 259,200 seconds by default.

Terms in this lesson

open redirect
A page that redirects to any address taken from the request, so that a link to a trusted site can send its visitors on to another site.
session
Data Django keeps on the server for one browser, found again on each request through the random key in the browser's sessionid cookie.
session fixation
An attack in which a victim is made to sign in with a session key the attacker already knows; giving the session a new key at sign-in, as login() does, defeats it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.