---
id: "CVE-2026-114137"
kind: "vulnerability"
aliases: ["CVE-2026-114137", "GHSA-xw6w-gcfp-cf8m"]
title: "Django REST framework: TokenProxy.delete() reports success and revokes nothing"
severity: "high"
cvss: 7.400000095367432
epss: 0.0035000001080334187
exploited: false
status: "open"
category: "package"
products: [{"name": "djangorestframework", "fixed": ["3.18.2"], "affected": ">= 3.12.0, < 3.18.2", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-390", "cwe-613", "django", "djangorestframework", "package", "python", "self-hosted"]
not_affected_if: ["no-token-revocation-via-proxy", "version-3.18.2-or-later", "version-below-3.12.0"]
published_at: "2026-10-21T21:17:56+00:00"
first_seen_at: "2026-10-23T19:11:31+00:00"
updated_at: "2026-10-23T21:57:29+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-xw6w-gcfp-cf8m", "first_seen_at": "2026-10-23T19:11:31+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-114137", "first_seen_at": "2026-10-23T21:57:29+00:00"}]
---

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

Calling 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, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:djangorestframework affected >= 3.12.0, < 3.18.2 fixed 3.18.2

## How to check

Search your code for calls to .delete() on TokenProxy instances outside DRF's TokenAdmin.delete_model, for example: grep -rn "TokenProxy" . and check the return value of any delete() call (it returns (0, {'authtoken.TokenProxy': 0}) when nothing was removed).
Check the installed version: pip show djangorestframework (affected >= 3.12.0, < 3.18.2).

## Fix

Upgrade djangorestframework to 3.18.2 or later.
Until then, revoke tokens with Token.objects.get(key=...).delete() or Token.objects.filter(user=user).delete() and confirm the returned count is non-zero.

## Timeline

- 2026-10-23T19:11:31+00:00 ghsa https://github.com/advisories/GHSA-xw6w-gcfp-cf8m
- 2026-10-23T21:57:29+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-114137

## From the sources

### 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`):

```python
@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:

```sql
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:

```python
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.

```python
#!/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 

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
