October 29, 2026
Django’s October 2026 Security Release: What the Four CVEs Mean for Your App
A practical triage guide to Django 6.1.2, 6.0.9, and 5.2.18, from the formset bug that can delete the wrong rows to the GeoDjango change…

By Lycore
9 min read
A practical triage guide to Django 6.1.2, 6.0.9, and 5.2.18, from the formset bug that can delete the wrong rows to the GeoDjango change that will break code on upgrade.
On October 6, 2026, the Django team shipped Django 6.1.2, 6.0.9, and 5.2.18. The releases fix four security issues and strengthen an earlier fix that turned out to be incomplete. The announcement asks all users to upgrade as soon as possible.
Most teams read an announcement like that, bump the version, and move on. That's usually fine. This time, though, the release deserves a closer look for two reasons. One fix is backward incompatible, so upgrading can break working code. Another covers a class of bug, model formsets with editable primary keys, that you should audit for no matter which version you're on.
This post walks through each issue. For each one it covers who is affected, what changed, and what to do beyond pip install -U django.
What shipped
All three supported release series got patches:
- Django 6.1.2, for the current feature release
- Django 6.0.9, for 6.0, which is past mainstream support and now gets only security and data-loss fixes
- Django 5.2.18, for the 5.2 LTS
The issues:
- CVE-2026-77050 (low): memory-based DoS via very long language codes
- CVE-2026-84429 (moderate): quadratic-time DoS in HTTP header parameter parsing
- CVE-2026-87890 (moderate): request forgery via raw
bytesin GeoDjango spatial lookups - CVE-2026-87975 (moderate): privilege abuse in model formsets with editable primary keys
- Follow-up to CVE-2026-15830 (no new CVE): a bypass of the nested-geometry depth limiter
Severity ratings come from Django's security policy. Keep in mind that "moderate" describes the bug in general, not the risk to your app. One of these could be harmless for you and serious for the next team, depending on how you use the affected feature.
Start here: which issues actually touch your code?
Before reading the details, answer four questions about your codebase:
- Do you use model formsets or inline formsets (
modelformset_factory,inlineformset_factory, or admin inlines) on models whose primary key isn't the default auto-increment? If so, read the CVE-2026-87975 section closely. - Do you use GeoDjango raster lookups and pass raw
bytesinto them? If so, the upgrade will break that code on purpose. - Is your app reachable by unauthenticated users? If so, the header-parsing DoS applies to you. Almost every public Django site falls into this group.
- Do you use i18n with language negotiation? The language-code issue is low severity, but it's in the request path.
If none of these fit, you still need to upgrade, but you can treat it as a routine patch release.
CVE-2026-87975: model formsets with editable primary keys
This one matters most for typical business apps, because formsets are everywhere: bulk-edit screens, inline line items, admin inlines.
What the bug was
According to the release notes, model formsets "incorrectly allowed forged POST data to either delete instances outside the limiting queryset or create instances via edit-only formsets when the model's primary key could be set through the form."
The advisory lists the cases where the primary key can be set through the form:
- a
OneToOneFieldused as the primary key, including a parent link used as the primary key of an inline formset's model - a natural or UUID primary key that is included in the form's fields
Models that use the default BigAutoField primary key were not affected.
So the risk looks like this. You build a formset scoped to the current user's objects with queryset=...filter(owner=request.user). You expect the formset to touch nothing outside that queryset. On an unpatched version, someone who forges the POST body could delete rows outside it, or create rows through a formset you had marked edit_only=True.
What vulnerable code tends to look like
Here's a pattern common in SaaS codebases that use UUID keys:
import uuid
from django.db import models
from django.forms import modelformset_factory
class Device(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
owner = models.ForeignKey("auth.User", on_delete=models.CASCADE)
label = models.CharField(max_length=100)
DeviceFormSet = modelformset_factory(
Device,
fields="__all__", # includes the UUID primary key
can_delete=True,
edit_only=True,
)
def manage_devices(request):
scoped = Device.objects.filter(owner=request.user)
formset = DeviceFormSet(request.POST or None, queryset=scoped)
if request.method == "POST" and formset.is_valid():
formset.save()
...import uuid
from django.db import models
from django.forms import modelformset_factory
class Device(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4)
owner = models.ForeignKey("auth.User", on_delete=models.CASCADE)
label = models.CharField(max_length=100)
DeviceFormSet = modelformset_factory(
Device,
fields="__all__", # includes the UUID primary key
can_delete=True,
edit_only=True,
)
def manage_devices(request):
scoped = Device.objects.filter(owner=request.user)
formset = DeviceFormSet(request.POST or None, queryset=scoped)
if request.method == "POST" and formset.is_valid():
formset.save()
...Two things stand out. The UUID primary key is editable and lands in the form through fields="__all__". And the code relies on the formset's queryset and edit_only as its only authorization boundary. The patch fixes Django's handling of this case. The code above is still fragile.
What to do besides upgrading
1. List fields explicitly. The ModelForm documentation already "strongly recommends" setting fields explicitly. It warns that including all fields automatically "has led to serious exploits on major websites." Keep editable primary keys out of fields unless users really need to set them.
DeviceFormSet = modelformset_factory(
Device,
fields=["label"],
can_delete=True,
edit_only=True,
)DeviceFormSet = modelformset_factory(
Device,
fields=["label"],
can_delete=True,
edit_only=True,
)2. Add an authorization check in your own code. Don't let the formset be the only thing between a forged request and your data. With save(commit=False), Django returns unsaved instances, and you have to delete formset.deleted_objects yourself. That gives you a natural place to check ownership:
if formset.is_valid():
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
if obj.owner_id != request.user.id:
raise PermissionDenied
obj.delete()
for obj in instances:
if obj._state.adding:
raise PermissionDenied # this screen never creates devices
if obj.owner_id != request.user.id:
raise PermissionDenied
obj.save()if formset.is_valid():
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
if obj.owner_id != request.user.id:
raise PermissionDenied
obj.delete()
for obj in instances:
if obj._state.adding:
raise PermissionDenied # this screen never creates devices
if obj.owner_id != request.user.id:
raise PermissionDenied
obj.save()This is defense in depth, not a replacement for the patch. It also protects you from the next bug in this class.
3. Write a regression test that forges the POST. Build a POST payload by hand. Include a hidden primary key value for an object owned by another user, and set its DELETE flag. Assert that the object still exists afterward. If you can, run the test against the old Django version once to confirm it catches the problem.
4. Search for risky formsets. In most codebases a quick search finds the candidates:
rg -n "modelformset_factory|inlineformset_factory|InlineModelAdmin|TabularInline|StackedInline" --type py
rg -n "primary_key=True" --type pyrg -n "modelformset_factory|inlineformset_factory|InlineModelAdmin|TabularInline|StackedInline" --type py
rg -n "primary_key=True" --type pyLook for any model that shows up in both results and doesn't use the default auto-incrementing key.
CVE-2026-84429: header parsing DoS
What the bug was
django.utils.http.parse_header_parameters() had quadratic time complexity when it parsed a value with many separators inside a quoted parameter. The advisory notes that an unauthenticated request could reach this code through headers such as Accept or Content-Type, for example through the content negotiation in HttpRequest.accepts(). It also notes that the existing per-call length limit "does not bound the combined size of repeated headers."
What changed
The function now uses Python's email.message.Message for parsing. The release notes warn that some malformed or unusual header values may now parse differently. For example, RFC 2231 values with a missing encoding are now decoded.
parse_header_parameters() is undocumented, but some third-party packages and in-house middleware call it anyway. If your code does, check the edge cases in its tests after upgrading:
rg -n "parse_header_parameters" .rg -n "parse_header_parameters" .What to do besides upgrading
Limit header sizes at the edge as well. Reverse proxies and load balancers can cap header size and count before a request ever reaches Python. This doesn't replace the patch, since a single header can still be expensive. But it narrows the attack surface for this kind of bug, and it costs almost nothing to set up.
CVE-2026-87890: GeoDjango raster bytes (backward incompatible)
If you don't use GeoDjango, skip this section. If you do, read it before you upgrade, because this change can break code.
What the bug was
Spatial lookups accepted raster values given as raw bytes without requiring them to be wrapped in GDALRaster. GDAL opened those bytes through its in-memory virtual filesystem. But the bytes could contain a VRT document that points to an external raster source, which could make GDAL send network requests as the Django process user. That is a server-side request forgery vector. The advisory says the issue was missed in the earlier fix for CVE-2026-15307.
What changed
Raw bytes raster values must now be wrapped in GDALRaster before you use them in spatial lookups. Byte values that represent valid hexadecimal geometries are still accepted.
Before:
# Worked before the patch; raster bytes are now rejected.
Elevation.objects.filter(rast__intersects=uploaded_file.read())# Worked before the patch; raster bytes are now rejected.
Elevation.objects.filter(rast__intersects=uploaded_file.read())After:
from django.contrib.gis.gdal import GDALRaster
raster = GDALRaster(uploaded_file.read())
Elevation.objects.filter(rast__intersects=raster)from django.contrib.gis.gdal import GDALRaster
raster = GDALRaster(uploaded_file.read())
Elevation.objects.filter(rast__intersects=raster)Read the change correctly: wrapping makes the conversion explicit, but it doesn't make untrusted bytes safe. The release notes end with a reminder that "all untrusted user input should be validated before use." If users can upload rasters, check the format before the data reaches GDAL, and reject formats you don't need to accept.
CVE-2026-77050: very long language codes
get_supported_language_variant() used language codes as keys in an in-memory cache before it limited their length. Many distinct, very long codes could use up process memory. Language codes longer than 500 characters are now rejected or truncated before the cached lookup.
The severity is low and no code changes are needed. If you have custom middleware that pulls language codes from URLs, cookies, or headers and passes them on, the patch protects you as long as you call through Django's function.
The CVE-2026-15830 follow-up: nested geometries
An earlier release added a depth limit to stop deeply nested geometry collections from reaching GEOS. That limit could be bypassed with certain WKB geometries. The new releases strengthen the original fix without a new CVE. According to the 6.1.2 notes, the new algorithm handles WKB and WKT input more consistently, and it also fixes a rare case where a valid geometry could have been rejected.
Non-security fixes worth knowing about in 6.1.2
The 6.1.2 release notes also list ordinary bug fixes. Some of them matter on their own:
- Oracle and
UUID4: A Django 6.1 bug caused theUUID4database function to store uppercase values on Oracle. The notes say existing data created this way "should be migrated to lowercase so that it can be queried correctly." If you're on Oracle and adoptedUUID4in 6.1, this needs a data migration, not just an upgrade. FETCH_PEERSandselect_related(): Instances loaded throughselect_related()weren't batched, which caused unnecessary queries.- Chained
RenameModelmigrations: Permissions weren't renamed correctly across chained renames. Permissions with matching codenames on other models in the same app could also be renamed by mistake. If you've squashed or chained model renames recently, check yourauth_permissionrows. - A data-loss fix dating back to 4.0: GeoDjango deleted network rasters when they were closed.
A low-drama upgrade playbook
Here's the sequence we use for patch releases like this one.
1. Pin, then bump. Make the version change its own commit, so you can revert it on its own if needed:
# requirements.in or pyproject.toml
Django>=6.1.2,<6.2# requirements.in or pyproject.toml
Django>=6.1.2,<6.22. Scan the whole dependency tree. Django is rarely the only outdated package. A tool like pip-audit checks your environment against known-vulnerability databases and runs easily in CI:
pip install pip-audit
pip-auditpip install pip-audit
pip-audit3. Run the targeted searches from this post (formsets, primary_key=True, parse_header_parameters, raster lookups) and add regression tests where you find matches.
4. Run your full test suite with deprecation warnings visible:
python -Wd manage.py testpython -Wd manage.py test5. Deploy to staging first if you use GeoDjango or touch the Oracle UUID4 case. For everyone else this patch should go out quickly.
6. Confirm what's actually running. A pinned version in a requirements file doesn't prove that production picked it up:
python -c "import django; print(django.get_version())"python -c "import django; print(django.get_version())"The bigger picture: support windows and what's next
This release is also a good time to check where your projects sit on Django's support timeline. According to the supported versions table:
- 5.2 LTS gets security and data-loss fixes until April 2028.
- 6.0 ended mainstream support on August 4, 2026, and gets extended support until April 2027.
- 6.1 has mainstream support until April 2027 and extended support until December 2027.
If you're on 6.0, start planning the move to 6.1 now rather than in March.
Django's versioning is also changing. The download page says that starting with Django 2028, feature releases will use a YYYY version format and ship every January, and every feature release will get the same three-year support period. Django 6.2 will be the last release under the old scheme. Over the long run that makes planning easier, but it's worth reviewing how you pin and schedule upgrades.
Two more operational notes:
- Another patch release is already on the calendar. The Django 6.1.3 release notes are dated November 3, 2026. They describe fixes for five low-severity issues and one moderate-severity issue, plus compatibility with Python 3.15. Depending on when you read this, 6.1.3 may already be out, so upgrade to the latest patch in your series rather than stopping at 6.1.2.
- The way to report vulnerabilities has changed. On October 8, 2026, the Django Security Team announced that it no longer accepts new reports through HackerOne. Reports now go by email to
security@djangoproject.com, as described in the security policy. If your internal runbook links to Django's HackerOne page, update it.
If you want warning before patch day, Django's security policy says the team notifies the django-announce mailing list about one week before public disclosure, with the date, approximate time, and severity of the upcoming release. Subscribing to that list is the simplest way to block out time before a release lands.
Conclusion
The October 2026 release isn't dramatic, but it's useful. Most of its value comes from what it tells you to check:
- Formsets aren't an authorization boundary. Scope the queryset, list fields explicitly, keep editable primary keys out of forms, and check ownership in your own code.
- Raw bytes going into GDAL is untrusted input and should be validated, wrapped, and handled on purpose.
- Public endpoints parse headers before your code runs, so apply limits at the edge as well as in the framework.
- "Patch release" doesn't always mean "drop-in". Read the notes for backward-incompatible changes and data migrations.
Upgrade to the latest patch in your series, run the searches above, add one or two forged-POST regression tests, and put the 6.0 to 6.1 migration on your roadmap.