11 Dec 2025
Django community aggregator: Community blog posts
Django 6.0 - Natalia Bidart
- Django 6.0 Released blog post and release notes
- Deprecations Don't Work for Python Libraries
- Django Forum - Should We Adjust Django's Versioning?
- django-repl and django-bolt
- Template Fragments essay on HTMX.org
- django-tasks
- What's New in Django 6.0 by Adam Johnson
- DjangoCon Europe 2024 | Empowering Django with Background Workers
Sponsor
This episode was brought to you by HackSoft, your development partner beyond code. From custom software development to consulting, team augmentation, or opening an office in Bulgaria, they're ready to take your Django project to the next level!
11 Dec 2025 11:05pm GMT
08 Dec 2025
Django community aggregator: Community blog posts
Django: implement HTTP basic authentication
Previously, we covered bearer authentication within HTTP's general authentication framework. In this post, we'll implement basic authentication, where the client provides a username and password.
To recap, HTTP's general authentication framework defines a general scheme for authentication:
- Clients may provide an
authorizationrequest header that contains a credential. - Servers validate the header and respond with either the requested resource or a 401 (Unauthorized) status code that includes a
www-authenticateresponse header advertising what authentication schemes are supported.
Basic authentication is an authentication scheme within the framework that browsers natively support. When accessing a page protected with basic authentication, browsers show a login prompt to the user, like this one in Firefox:

After the user enters their credentials, the browser sends a new request with the authorization header set to the string Basic <credentials>, where <credentials> is a base64-encoded string of the form <username>:<password>. The server can then validate the credentials and respond accordingly.
Basic authentication is not the best user experience, as the browser login dialog cannot be styled and password managers cannot autofill it. It also requires some security considerations, as the credentials are sent with every request to the protected resource, increasing the risk of exposure, but if you use HTTPS, as basically required for the modern web, this risk is somewhat mitigated. Despite these downsides, basic authentication is convenient for simple use cases, like temporarily password-protecting a work-in-progress page, as it's fast to implement without any HTML, CSS, or JavaScript. And sometimes it's just plain required to integrate with a legacy system.
Django doesn't provide built-in basic authentication, but it takes minimal code to implement it yourself. Django REST Framework does provide BasicAuthentication, but it's not a particularly convenient authentication method for APIs.
Here is a complete example of how to implement Basic authentication for a Django view:
import base64
import os
import secrets
from http import HTTPStatus
from django.shortcuts import render
USERNAME = os.environ.get("SECRET_STUFF_USERNAME", "")
PASSWORD = os.environ.get("SECRET_STUFF_PASSWORD", "")
def secret_stuff(request):
authorization = request.headers.get("authorization", "")
if not USERNAME or not PASSWORD:
# Unconfigured, deny all access
return _unauthorized(request)
if not authorization.startswith("Basic "):
return _unauthorized(request)
authorization = authorization.removeprefix("Basic ")
try:
credentials = base64.b64decode(authorization).decode("utf-8")
username, password = credentials.split(":", 1)
except (ValueError, UnicodeDecodeError):
return _unauthorized(request)
username_matches = secrets.compare_digest(username, SECRET_STUFF_USERNAME)
password_matches = secrets.compare_digest(password, SECRET_STUFF_PASSWORD)
if not username_matches or not password_matches:
return _unauthorized(request)
return render("secret_stuff.html", request)
def _unauthorized(request):
response = render(
request,
"unauthorized.html",
status=HTTPStatus.UNAUTHORIZED,
)
response.headers["www-authenticate"] = 'Basic realm="Secret area!"'
return response
For this example, there's a single valid username-password pair, provided through environment variables. If those environment variables are not set, the view denies all access, otherwise it checks that the authorization header is present and correctly formatted. Basic authentication credentials are base64-encoded strings of the form username:password, so the view decodes and splits the credentials accordingly, then compares them with the expected values using secrets.compare_digest() to avoid timing attacks.
If any step of the validation fails, the view responds with a 401 Unauthorized response, with the www-authenticate header advertising the Basic scheme. This header prompts browsers to show the login dialog. If the credentials are valid, the view renders the protected content.
To test this view, we can use Django's test client:
from base64 import b64encode
from http import HTTPStatus
from unittest import mock
from django.test import SimpleTestCase
from example import views
@mock.patch.multiple(
views,
SECRET_STUFF_USERNAME="admin",
SECRET_STUFF_PASSWORD="hunter2",
)
class SecretStuffTests(SimpleTestCase):
def test_unauthorized_no_header(self):
response = self.client.get("/secret-stuff/")
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
assert response.headers["www-authenticate"] == 'Basic realm="Secret area!"'
def test_unauthorized_unconfigured(self):
with mock.patch.multiple(
views, SECRET_STUFF_USERNAME="", SECRET_STUFF_PASSWORD=""
):
credentials = b64encode(b"admin:hunter2").decode()
response = self.client.get(
"/secret-stuff/",
headers={"authorization": f"Basic {credentials}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
assert response.headers["www-authenticate"] == 'Basic realm="Secret area!"'
def test_unauthorized_wrong_authorization_type(self):
response = self.client.get(
"/secret-stuff/",
headers={"authorization": "Bearer sometoken"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
assert response.headers["www-authenticate"] == 'Basic realm="Secret area!"'
def test_unauthorized_malformed_credentials(self):
malformed_credentials = b64encode(b"malformedcredentials").decode()
response = self.client.get(
"/secret-stuff/",
headers={"authorization": f"Basic {malformed_credentials}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
def test_unauthorized_wrong_username(self):
wrong_credentials = b64encode(b"wrong:hunter2").decode()
response = self.client.get(
"/secret-stuff/",
headers={"authorization": f"Basic {wrong_credentials}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
assert response.headers["www-authenticate"] == 'Basic realm="Secret area!"'
def test_unauthorized_wrong_password(self):
wrong_credentials = b64encode(b"admin:wrongpassword").decode()
response = self.client.get(
"/secret-stuff/",
headers={"authorization": f"Basic {wrong_credentials}"},
)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert "<h1>Unauthorized</h1>" in response.text
def test_authorized_access(self):
credentials = b64encode(b"admin:hunter2").decode()
response = self.client.get(
"/secret-stuff/",
headers={"authorization": f"Basic {credentials}"},
)
assert response.status_code == HTTPStatus.OK
assert "<h1>🤫 Secret stuff</h1>" in response.text
These tests cover all paths through the code, giving 100% coverage.
mock.patch.multiple() is particularly neat for setting the expected username and password during the tests, overriding any environment variable configuration.
Extract a decorator to curtail constant copying
If you have multiple views that need authentication, don't copy authentication code between them. Instead, extract the authentication logic into a view decorator, like:
import base64
import functools
import os
import secrets
from http import HTTPStatus
from django.shortcuts import render
SECRET_STUFF_USERNAME = os.environ.get("SECRET_STUFF_USERNAME", "")
SECRET_STUFF_PASSWORD = os.environ.get("SECRET_STUFF_PASSWORD", "")
def basic_auth(view_func):
@functools.wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
authorization = request.headers.get("authorization", "")
if not SECRET_STUFF_USERNAME or not SECRET_STUFF_PASSWORD:
return _unauthorized(request)
if not authorization.startswith("Basic "):
return _unauthorized(request)
authorization = authorization.removeprefix("Basic ")
try:
credentials = base64.b64decode(authorization).decode("utf-8")
username, password = credentials.split(":", 1)
except (ValueError, UnicodeDecodeError):
return _unauthorized(request)
username_matches = secrets.compare_digest(username, SECRET_STUFF_USERNAME)
password_matches = secrets.compare_digest(password, SECRET_STUFF_PASSWORD)
if not username_matches or not password_matches:
return _unauthorized(request)
return view_func(request, *args, **kwargs)
return _wrapped_view
def _unauthorized(request):
response = render(
request,
"unauthorized.html",
status=HTTPStatus.UNAUTHORIZED,
)
response.headers["www-authenticate"] = 'Basic realm="Secret area!"'
return response
@basic_auth
def secret_recommendation(request):
return render("secret_recommendation.html", request)
@basic_auth
def secret_info(request):
return render("secret_info.html", request)
Extensions
It may be sufficient to support just a single hardcoded username and password pair, as above, for example for a demo site that only a few trusted users will access. But in more complex scenarios, you may want to support multiple users and passwords, Django's user model integration, rate limiting, and so on. You can certainly extend the above code to support those features, though at some point it will probably make sense to switch to a classic login form instead, typically through Django's authentication framework.
08 Dec 2025 6:00am GMT
05 Dec 2025
Django community aggregator: Community blog posts
Weeknotes (2025 week 49)
Weeknotes (2025 week 49)
I seem to be publishing weeknotes monthly, so I'm now thinking about renaming the category :-)
Mosparo
I have started using a self-hosted mosparo instance for my captcha needs. It's nicer than Google reCAPTCHA. Also, not sending data to Google and not training AI models on traffic signs feels better.
Fixes for the YouTube 153 error
Simon Willison published a nice writeup about YouTube embeds failing with a 153 error. We have also encountered this problem in the wild and fixed the feincms3 embedding code to also set the required referrerpolicy attribute.
Updated packages since 2025-11-04
- django-sitemaps 2.0.2: Uploaded a new release which includes a wheel build. Rebuilding the wheel all the time when creating new container images was getting annoying. The code itself is unchanged.
- django-prune-uploads 0.3.1: The package now supports pruning a storage backed by django-s3-storage efficiently. I have also looked at django-prune-media but since the package uses the storage API instead of enumerating files using boto3 directly it's unusably slow for my use case.
- feincms3-forms 0.6: Much better docs and a new way to reference individual form fields in custom templates.
- django-json-schema-editor 0.11: Switched from JSON paths to
jmespathinstances. Made the JSON model instance reference support easier and more fun to use. Added new ways of customizing the generated proxy model for individual JSON plugin instances. - form-designer 0.27.1: Added support for the mosparo captcha to the default list of field types.
- asgi-plausible 0.1.1: No code change really, just added required dependencies to the package metadata.
- django-tree-queries 0.23: The package now ships a
OrderableTreeNodebase model which you can use when you want to order siblings manually. feincms3 already uses this base model for its pages model. - feincms3-data 0.10: This is quite a big one. I discovered issues with the way
save_as_new(to copy data) anddelete_missinginteracted. First the code was cleaned up to delete less data, and then to delete enough data. I'm now somewhat confident that the code does what it should again. - django-prose-editor 0.22.3: Started returning an empty string for an empty document instead of
<p></p>also when using the frontend integration; previously, this transformation was only implemented when using at least the form if not the model field. Also,<ol>tags now have adata-typeattribute since Chrome cannot case-sensitively match e.g.type="a"vstype="A"for lowercase or uppercase letters. I previously only tested the code in Firefox and there it worked nicely.
05 Dec 2025 6:00pm GMT