Live › vulnerability › CVE-2026-114137
Go to dashboardCalling delete() on a DRF TokenProxy instance reports success but deletes no row, because the proxy's pk returns the user id and the delete query matches it against the token key column. The token keeps authenticating, so an attacker or former user holding it retains access after an operator believes it was revoked.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-23 19:11 UTC, 3h before NVD. All times on this page are UTC.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| djangorestframework | pypi | >= 3.12.0, < 3.18.2 | 3.18.2 |
authtoken.TokenProxy: 0}) when nothing was removed)pip show djangorestframework (affected >= 3.12.0, < 3.18.2)3.18.2 or laterEach row is the first time that source carried this signal. The analyst’s rows are its own findings.
Deleting an authentication token through TokenProxy silently deletes nothing. The call raises no
exception and reports success, the database row survives, and the token keeps authenticating. Any
application, admin integration or operational script that revokes credentials by calling .delete()
on a TokenProxy instance believes it has revoked a token that is still live.
TokenProxy overrides pk to return the user id (rest_framework/authtoken/models.py:49-63):
@property
def pk(self):
return self.user_id
The concrete Token model keys on key, not on an integer id. Model.delete() builds its delete
query from pk, so it emits:
DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('1')
That matches a user id against the 40-character key column. Nothing matches, nothing raises, and the
call returns (0, {'authtoken.TokenProxy': 0}).
DRF already ships two workarounds for this same identity mismatch instead of a fix. In
rest_framework/authtoken/admin.py, TokenAdmin.delete_model re-fetches the concrete row:
def delete_model(self, request, obj):
Token.objects.get(key=obj.key).delete() # admin.py:30
and TokenAdmin sets actions = None with the comment "Actions not compatible with mapped IDs"
(admin.py:50-53). The shipped admin path is therefore safe, and every other caller of
TokenProxy.delete() is not. Nothing in the API signals the difference.
TokenProxy was added by e2bd3b6a (PR #7341, "Adjusted token admin to map to user ID"), first
released in 3.12.0.
A fix would drop the pk override, or give TokenProxy a delete() that operates on the concrete
row, or raise instead of silently reporting zero deletions.
Save as poc_tokenproxy_revocation.py and run it.
#!/usr/bin/env python
"""
django-rest-framework: TokenProxy.delete() reports success and revokes nothing.
baseline token exists -> 200
TRIGGER TokenProxy.objects.get(...).delete() -> no exception, row survives, still 200
CONTROL Token.objects.get(key=...).delete() -> row gone, 401
The control is what makes the trigger meaningful: it proves the row was
deletable and the credential check live, so the 200 after the proxy delete is a
failed revocation and not a broken harness.
Requires: django, djangorestframework importable. No arguments.
python poc_tokenproxy_revocation.py
Exit status 0 means the revocation failure reproduced.
"""
import sys
import django
from django.conf import settings
settings.configure(
DEBUG=False,
SECRET_KEY="poc",
ALLOWED_HOSTS=["*"],
ROOT_URLCONF=__name__,
INSTALLED_APPS=[
"django.contrib.contenttypes",
"django.contrib.auth",
"rest_framework",
"rest_framework.authtoken",
],
DATABASES={"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}},
DEFAULT_AUTO_FIELD="django.db.models.AutoField",
USE_TZ=True,
)
django.setup()
from django.contrib.auth import get_user_model # noqa: E402
from django.core.management import call_command # noqa: E402
from django.db import connection # noqa: E402
from django.test import Client # noqa: E402
from django.test.utils CaptureQueriesContext
django.urls path
rest_framework.authentication TokenAuthentication
rest_framework.authtoken.models Token, TokenProxy
rest_framework.permissions IsAuthenticated
rest_framework.response Response
rest_framework.views APIView
():
authentication_classes = [TokenAuthentication]
permission_classes = [IsAuthenticated]
():
Response({: request.user.get_username()})
urlpatterns = [path(, Whoami.as_view())]
():
response = client.get(, headers={: % key})
response.status_code
():
call_command(, run_syncdb=, verbosity=)
user = get_user_model().objects.create_user(username=, password=)
key = Token.objects.create(user=user).key
client = Client()
(, TokenProxy._meta.proxy)
(, Token._meta.pk.attname)
(, user.pk)
(, key)
()
(, Token.objects.count())
baseline = probe(client, key)
(, baseline)
()
proxy = TokenProxy.objects.get(user=user)
(, proxy.pk, )
raised =
CaptureQueriesContext(connection) ctx:
:
returned = proxy.delete()
Exception exc:
raised, returned = exc,
q ctx.captured_queries:
q[]:
(, q[])
(, (raised))
(, returned)
(, Token.objects.count())
after_proxy = probe(client, key)
(, after_proxy)
()
CaptureQueriesContext(connection) ctx2:
returned2 = Token.objects.get(key=key).delete()
q ctx2.captured_queries:
q[]:
(, q[])
(, returned2)
(, Token.objects.count())
after_real = probe(client, key)
(, after_real)
ok = baseline == raised after_proxy == after_real ==
(
, ok,
,
)
ok
__name__ == :
sys.exit(main())
Observed output:
TokenProxy._meta.proxy = True
Token._meta.pk.attname = key
user.pk = 1
token key = 6a644878f252175d39205804c5a6faf16df67c10
--- baseline
rows = 1
GET / with token -> 200
--- TRIGGER: delete through the proxy
proxy.pk = 1 (TokenProxy.pk property)
SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('1')
raised = None
returned = (0, {'authtoken.TokenProxy': 0})
rows = 1
GET / with token -> 200
--- CONTROL: delete the same row through the concrete model
SQL: DELETE FROM "authtoken_token" WHERE "authtoken_token"."key" IN ('6a644878...')
returned (, {: })
token
revocation failure reproduced:
The control is what makes the trigger meaningful: it proves the row was deletable and the credential check live, so the 200 after the proxy delete is a failed revocation and not a broken harness. Reproduced identically on Django 6.1 and 5.2.17 LTS, so this is not a recent Django regression.
Failed credential revocation. An operator who revokes a token through a TokenProxy instance, and
receives no error, leaves a live credential in circulation indefinitely, because DRF auth tokens do
not expire on their own. Whoever holds that token benefits: a departed employee, a rotated
integration, a device believed to be deprovisioned, or an attacker whose access was thought to have
been cut during incident response.
No untrusted input reaches this code path, which is why the vector is scored AC:H. Exploitation
depends on the deployment
This page as .md · .json · in your agent: get_signal("CVE-2026-114137")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.