01 Oct 2026
Django community aggregator: Community blog posts
Django: serve a security.txt file
When a security researcher finds a vulnerability in your site, they need a way to tell you about it. Without a clear contact, they may resort to guessing at addresses like security@<yourdomain>, messaging random folks on social media, or give up. And of course, in the worst case, they might just publish the details, leaving you to find out when attackers do.
security.txt is a web standard to fix this problem. It's a small text file, served at the reserved path /.well-known/security.txt, that says how to report security issues to your organization. It was standardized in April 2022 as RFC 9116.
In this post, we'll look at serving a security.txt file and adding unit tests and a system check to keep it current.
Write the file
A security.txt file contains a series of Field: value lines, plus optional comments starting with #. Here's an example:
# Security contact information for example.com
Contact: mailto:security@example.com
Expires: 2027-09-01T00:00:00Z
Preferred-Languages: en
Canonical: https://example.com/.well-known/security.txt
Policy: https://example.com/security/
The two required fields are:
Contact: Gives a way to reach you, as a URI. That's normally amailto:email address, but it can also be anhttps:URL for a web page or form, or atel:phone number. You can list severalContactlines, in order of preference.Expires: Gives a date and time after which the file should be considered stale, in RFC 3339 format. It must appear exactly once. The RFC recommends setting it less than a year in the future.
Expires exists because contact details rot-folks leave, mailboxes get deleted, and bug bounty programmes close. An expiry date forces you to periodically confirm that the file is still accurate, and it tells researchers not to trust it if you forget.
To write your own security.txt file, use the handy dandy form at securitytxt.org. That beats copy-pasta'ing the above example, and lets you pick from all the possible fields.
Note that a security.txt file only applies to the domain that serves it. If you have several domains or subdomains, such as api.example.com, each needs to serve a file. You can serve the same file on each, with one Canonical line per domain.
Serve the file
The RFC requires that you serve security.txt over HTTPS, with the content type text/plain and a charset of utf-8. HTTPS should come from your production setup, such as your load balancer or the SECURE_SSL_REDIRECT setting, so the view only needs to handle the content type bit.
Below is a view that serves it appropriately, assuming your security.txt file is in the same directory as the views module file.
from pathlib import Path
from django.contrib.auth.decorators import login_not_required
from django.http import FileResponse, HttpRequest, HttpResponse
from django.views.decorators.cache import cache_control
from django.views.decorators.http import require_safe
SECURITY_TXT_PATH = Path(__file__).parent / "security.txt"
@login_not_required
@require_safe
@cache_control(max_age=60 * 5, public=True) # 5 minutes
def security_txt(request: HttpRequest) -> HttpResponse:
"""
Serve the security.txt file, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
return FileResponse(
SECURITY_TXT_PATH.open("rb"),
content_type="text/plain; charset=utf-8",
)
β¦with this corresponding entry in your root URLconf:
from django.urls import path
from example.core import views as core_views
urlpatterns = [
# ...
path(".well-known/security.txt", core_views.security_txt),
# ...
]
Deconstructing the view code:
@login_not_requiredmarks the view as public, for projects using Django'sLoginRequiredMiddleware, added in Django 5.1. I highly recommend using this middleware, as it makes your site more secure by default! If you aren't using it, you can skip this decorator, but it is harmless to leave it in place.@require_saferestricts the view to the "safe" HTTP methods: GET and HEAD.@cache_controlsets theCache-Controlheader so browsers and CDNs can cache the file for five minutes. That's a small bit of load protection which might help if a researcher hammers your site with a vulnerability scanner.SECURITY_TXT_PATHpoints to the file, relative to the views module, using pathlib. If you put the file elsewhere, adjust the path.- Django's
FileResponsestreams the file as the response body. - The explicit
content_typeadds the RFC-compliant content type, rather thanFileResponse's default guess (justtext/plain, no charset).
After adding the URL, you can check it in your browser, for example at http://localhost:8000/.well-known/security.txt.
Add tests
It's test time! Test time is the best time! Here's a test case covering the view, which you could put in your app's tests.py:
import datetime as dt
from http import HTTPStatus
from django.test import SimpleTestCase
class SecurityTxtTests(SimpleTestCase):
"""
Test the security.txt file, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
def test_success(self):
response = self.client.get("/.well-known/security.txt")
assert response.status_code == HTTPStatus.OK
assert response["content-type"] == "text/plain; charset=utf-8"
assert response["cache-control"] == "max-age=300, public"
# Parse the security.txt format
content = response.getvalue().decode()
fields: dict[str, list[str]] = {}
for line in content.splitlines():
name, sep, value = line.partition(": ")
if sep and not line.startswith("#"):
fields.setdefault(name, []).append(value)
assert fields["Contact"] == ["mailto:security@example.com"]
assert len(fields["Expires"]) == 1
expires = dt.datetime.fromisoformat(fields["Expires"][0])
assert expires.tzinfo is not None
def test_head(self):
response = self.client.head("/.well-known/security.txt")
assert response.status_code == HTTPStatus.OK
def test_post_disallowed(self):
response = self.client.post("/.well-known/security.txt")
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
Notes:
FileResponseis a streaming response, sotest_success()needs to read the whole body withgetvalue(), instead of thetextattribute.test_success()checks the headers set by the view, including thecache-controlheader from@cache_control.test_success()parses the file into a dictionary mapping field names to lists of values, skipping comments. It then checks theContactvalue, so swap in your own.- The
Expireschecks ensure there's exactly one value, which parses withdatetime.fromisoformat()and includes a timezone, as the RFC requires. test_head()andtest_post_disallowed()check the effect of@require_safe.
Add a system check for expiry
The tests check that Expires is valid, but not that it's current. You need to keep confirming the data and bumping that field every year or so, to ensure your file still gets used.
To ensure that your file goes near, you need some kind of system. You could add a test that fails when the date is near, but that would start failing on some arbitrary day, blocking unrelated work. Instead, you can add a custom system check that warns when the file needs attention. (Or you could set a calendar alert, if that works for you!)
Django runs system checks at the start of most management commands, including runserver, migrate, and test. Warnings are displayed without stopping the command, so they nag without blocking.
Here's such a check, ready to be pasted in a checks.py module next to the views module:
import datetime as dt
from django.core import checks
from example.core.views import SECURITY_TXT_PATH
@checks.register
def check_security_txt_expires(app_configs, **kwargs):
"""
Check the security.txt file is current, per:
https://adamj.eu/tech/2026/09/30/django-security-txt/
"""
for line in SECURITY_TXT_PATH.read_text().splitlines():
if line.startswith("Expires: "):
value = line.removeprefix("Expires: ")
break
else:
return [
checks.Error(
"security.txt has no Expires field.",
id="core.E001",
)
]
expires = dt.datetime.fromisoformat(value)
remaining = expires - dt.datetime.now(dt.timezone.utc)
if remaining < dt.timedelta(days=30):
return [
checks.Warning(
f"security.txt expires on {expires:%Y-%m-%d}.",
hint="Review security.txt and update its Expires field.",
id="core.W001",
)
]
if remaining > dt.timedelta(days=365):
return [
checks.Warning(
"security.txt expires more than a year from now.",
hint="The RFC recommends an Expires under a year out.",
id="core.W002",
)
]
return []
Import the module in your app config's ready() method, so the @checks.register decorator runs:
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = "example.core"
def ready(self):
from example.core import checks # noqa: F401
Notes:
- The check function starts with a mini parser to find the
Expiresentry, or fail if it's missing. - The function emits a check warning when your file is within 30 days of expiring, or past.
- It emits a second warning to enforces the RFC's recommendation to keep
Expiresunder a year out.
With the check in place, when expiry nears, all Django commands will show a warning like:
$ ./manage.py check
System check identified some issues:
WARNINGS:
?: (core.W001) security.txt expires on 2027-09-01.
HINT: Review security.txt and update its Expires field.
System check identified 1 issue (0 silenced).
Then, hopefully, someone will see the warning and remember to update the file.
Fin
So there we go: put up one small text file and watch the vulnerability reports pour in. Well, hopefully trickle.
May your security reports be few, slop-free, and low impact,
-Adam
01 Oct 2026 4:00am GMT
30 Sep 2026
Django community aggregator: Community blog posts
Weeknotes (2026 week 40)
Weeknotes (2026 week 40)
I have been at Django on the Med ποΈ and already wrote a lengthy post about that.
I did a lot of work on a DEP for adding import map support to Django which is currently also being discussed on the forum. Apart from that I'm not going to repeat anything from the post linked above, so check it out if you want to know more.
Motivated by a discussion I had at the sprint I also improved my release process. I now have a make-release script in my dotfiles which updates the CHANGELOG with the version, bumps the version itself in the repo and commits and tags the release. The rest is handled by trusted publishing. I'm now finally also properly handling patch releases so that you don't have to check the history to know what's in a patch release. I already did that for minor and major version bumps, but was a bit too lazy. Now I can be even lazier and still more correct, and it feels great.
Next, I refactored the static site generator script for this blog to be much faster. I now do not have to wait when saving before refreshing the browser. Much nicer.
Releases from the last three weeks
django-js-asset
django-js-asset 5.0a1 implements the API proposed in the DEP. This is an alpha release because the DEP is still being discussed and I don't want to break people's code again and again if, during discussion, it appears that the API should be different.
django-json-schema-editor, django-prose-editor and django-content-editor
The three alpha releases django-json-schema-editor 0.15a1, django-prose-editor 0.28a2 and django-content-editor 9.1a1 depend on django-js-asset 5.0a1 mentioned above and implement the necessary changes for the new import map definition style.
django-authlib
django-authlib 0.20 adds support for specifying the tenant when using Microsoft Entra ID.
feincms3-downloads
feincms3-downloads 0.6 includes translation fixes, uses different error codes when pdftocairo or convert are missing, and changed the PATH environment variable handling to be less annoying for local development.
30 Sep 2026 5:00pm GMT
Django on the Med
π Links
- Django on the Med
- New technical governance approved (DEP 19)
- Executive director search extended
- DjangoCon Europe 2027 (Innsbruck, Austria)
- django-bgt
- django-benchmark
- Open DEPs pull requests from Django on the Med
π Books
- The Narrow Road to the Deep North and Other Travel Sketches by Matsuo Basho
- The Wall by Marlen Hausofer
π₯ YouTube
30 Sep 2026 3:00pm GMT