Skip to content

Django REST framework: TokenProxy.delete() reports success and revokes nothing

High severity GitHub Reviewed Published Oct 6, 2026 in encode/django-rest-framework • Updated Oct 22, 2026

Package

pip djangorestframework (pip)

Affected versions

>= 3.12.0, < 3.18.2

Patched versions

3.18.2

Description

Summary

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.

Details

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.

PoC

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 import CaptureQueriesContext  # noqa: E402
from django.urls import path  # noqa: E402

from rest_framework.authentication import TokenAuthentication  # noqa: E402
from rest_framework.authtoken.models import Token, TokenProxy  # noqa: E402
from rest_framework.permissions import IsAuthenticated  # noqa: E402
from rest_framework.response import Response  # noqa: E402
from rest_framework.views import APIView  # noqa: E402


class Whoami(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def get(self, request):
        return Response({"user": request.user.get_username()})


urlpatterns = [path("", Whoami.as_view())]


def probe(client, key):
    response = client.get("/", headers={"authorization": "Token %s" % key})
    return response.status_code


def main():
    call_command("migrate", run_syncdb=True, verbosity=0)
    user = get_user_model().objects.create_user(username="revoke-user", password="pw")
    key = Token.objects.create(user=user).key
    client = Client()

    print("TokenProxy._meta.proxy   =", TokenProxy._meta.proxy)
    print("Token._meta.pk.attname   =", Token._meta.pk.attname)
    print("user.pk                  =", user.pk)
    print("token key                =", key)

    print("\n--- baseline")
    print("rows                     =", Token.objects.count())
    baseline = probe(client, key)
    print("GET / with token         ->", baseline)

    print("\n--- TRIGGER: delete through the proxy")
    proxy = TokenProxy.objects.get(user=user)
    print("proxy.pk                 =", proxy.pk, "(TokenProxy.pk property)")
    raised = None
    with CaptureQueriesContext(connection) as ctx:
        try:
            returned = proxy.delete()
        except Exception as exc:  # noqa: BLE001
            raised, returned = exc, None
    for q in ctx.captured_queries:
        if "DELETE" in q["sql"]:
            print("    SQL:", q["sql"])
    print("raised                   =", repr(raised))
    print("returned                 =", returned)
    print("rows                     =", Token.objects.count())
    after_proxy = probe(client, key)
    print("GET / with token         ->", after_proxy)

    print("\n--- CONTROL: delete the same row through the concrete model")
    with CaptureQueriesContext(connection) as ctx2:
        returned2 = Token.objects.get(key=key).delete()
    for q in ctx2.captured_queries:
        if "DELETE" in q["sql"]:
            print("    SQL:", q["sql"])
    print("returned                 =", returned2)
    print("rows                     =", Token.objects.count())
    after_real = probe(client, key)
    print("GET / with token         ->", after_real)

    ok = baseline == 200 and raised is None and after_proxy == 200 and after_real == 401
    print(
        "\nrevocation failure reproduced:", ok,
        "(baseline 200, still 200 after proxy delete, 401 after real delete)",
    )
    return 0 if ok else 1


if __name__ == "__main__":
    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                 = (1, {'authtoken.Token': 1})
rows                     = 0
GET / with token         -> 401

revocation failure reproduced: True

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.

Impact

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 revoking through the proxy, which is a natural thing to do given
TokenProxy is the model registered in the admin and the one most code will reach for. Any
deployment on 3.12.0 or later that revokes tokens outside DRF's own TokenAdmin.delete_model should
audit whether its revocations took effect.

References

Published by the National Vulnerability Database Oct 21, 2026
Published to the GitHub Advisory Database Oct 22, 2026
Reviewed Oct 22, 2026
Last updated Oct 22, 2026

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(41st percentile)

Weaknesses

Detection of Error Condition Without Action

The product detects a specific error, but takes no actions to handle the error. Learn more on MITRE.

Insufficient Session Expiration

According to WASC, Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization. Learn more on MITRE.

CVE ID

CVE-2026-114137

GHSA ID

GHSA-xw6w-gcfp-cf8m

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.