A stolen Weblate API token (
wlu_…) keeps working even after the victim changes their password. Browser sessions are correctly cycled; DRFauthtokenrows are not — so password rotation is not a complete incident-response action against a token that has already leaked.
| CVE | CVE-2026-41519 (NVD) |
| GHSA | GHSA-6j8j-4qp3-36p2 |
| Severity | Moderate — CVSS 4.2 |
| Vector | CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:N |
| CWE | CWE-613: Insufficient Session Expiration |
| Affected | Weblate < 5.17.1 |
| Fixed | 5.17.1 |
| Authentication | Pre-existing token holder (the bug is about retention of access already obtained) |
| Reporter | @whatisproblem |
| Fix PR | WeblateOrg/weblate#19057 (merged 2026-04-17, commit 649a2da) |
Weblate is a self-hosted continuous-localisation platform built on Django. Every Weblate user account is automatically issued a Django REST Framework (DRF) authtoken on creation, prefixed wlu_ for human users (wlp_ for bots). That token grants full API-level access on the user's behalf and is never sent through Django's normal session machinery.
When a user changes their password — through the in-app "change password" form or via the password-reset-by-email flow — Weblate's SetPasswordForm.save() correctly invalidates browser sessions (via cycle_session_keys()) and clears outstanding password-reset codes. It does not touch the user's API token row. The wlu_… value continues to authenticate against every Weblate API endpoint as if nothing happened.
The realistic threat model: a token leaks (committed to a public repo, captured by malware, exfiltrated by a contractor, sniffed off a misconfigured CI runner), the user notices and rotates their password, and assumes that has cut off the attacker. It hasn't.
Pre-fix, the password-change form in weblate/accounts/forms.py — SetPasswordForm.save():
@transaction.atomic
def save(self, request, delete_session=False):
super().save(commit=True)
# Cycle session keys so all browser sessions are invalidated
cycle_session_keys(request, self.user)
# Invalidate password-reset codes
invalidate_reset_codes(self.user)
# ← nothing here for rest_framework.authtoken.Token
if delete_session:
request.session.flush()Token creation happens once, in weblate/accounts/models.py — create_profile_callback (a post_save signal):
if created:
instance.auth_token = Token.objects.create(
user=instance,
key=get_token("wlp" if instance.is_bot else "wlu"),
)…and the only place a user's Token row is rewritten before this fix is the manual reset_api_key view, which the user must explicitly visit and click. Password change has no link into that path.
The authtoken_token row is keyed by user but its key column is the actual bearer credential. As long as the row exists and the key is unchanged, the API client holding that key authenticates.
DRF authtoken is a "long-lived bearer token" model: there is no expiry, no refresh, no per-request server-side validation beyond row lookup. Tokens leak through the same channels that web sessions don't:
- CI/CD configurations — committed to
.envfiles, GitHub Actions secrets exfiltrated by a malicious workflow, hard-coded in scripts. - Local tooling — saved in
~/.config/<some-tool>/credentials, rsync'd to backups, surfaced byls -lato coworkers. - Browser extensions / IDE plugins that wrap the Weblate API and store tokens in plaintext config.
- Supply-chain compromise of a Weblate-integrating package that captures the token at runtime.
In every one of those scenarios, the canonical user reaction is "I'll change my password" — which closes the browser session vector but leaves the token vector wide open. That mismatch between user mental model and actual revocation behaviour is the bug.
CVSS 4.2 (Moderate) reflects that this is a defence-in-depth failure, not an initial-compromise primitive. The attacker must already have the token. But "post-incident hygiene fails silently" is exactly the class of bug that turns a one-time compromise into persistent access.
git clone https://github.com/WeblateOrg/weblate
cd weblate && git checkout weblate-5.17.0 # last vulnerable release
docker compose -f docker-compose.yml up -dWait for the stack to come up. Create an admin via weblate createadmin if needed.
Sign in as a normal user. Visit /accounts/profile/#api to read the user's API token (or query the DB directly):
SELECT u.username, t.key, t.created
FROM authtoken_token t JOIN auth_user u ON u.id = t.user_id
WHERE u.username = 'victim';Save the value, e.g. wlu_AbCdEf1234….
curl -H "Authorization: Token wlu_AbCdEf1234…" \
http://localhost:8080/api/users/current/
# → 200 OK with the victim's user JSONAs the victim user, navigate to Account → Password and submit a new password through the standard SetPasswordForm. (Or trigger a password-reset-by-email flow and complete it.) The success flash reads "Your password has been changed".
curl -H "Authorization: Token wlu_AbCdEf1234…" \
http://localhost:8080/api/users/current/
# → 200 OK — same user, same tokenBrowser cookies issued before the change are dead (cycle_session_keys handled that). The API token isn't.
Demonstrative scope only. A real attacker would have captured the token via the leakage vectors enumerated above; the PoC above just demonstrates persistence by manually reading the row. No data is exfiltrated beyond the user's own profile JSON.
Patched in Weblate 5.17.1 by PR #19057 (merge commit 649a2da).
The patch refactors token-lifecycle code into three reusable helpers in weblate/accounts/utils.py:
def create_api_token(user: User) -> Token:
return Token.objects.create(
user=user, key=get_token("wlp" if user.is_bot else "wlu")
)
def delete_api_tokens(user: User) -> None:
Token.objects.filter(user=user).delete()
def reset_api_token(user: User) -> Token:
delete_api_tokens(user)
return create_api_token(user)…and adds a checkbox to SetPasswordForm:
regenerate_api_key = forms.BooleanField(
label=gettext_lazy("Regenerate API key"),
help_text=gettext_lazy(
"Leave enabled to revoke the current API key and generate a new one. "
"This is recommended if you suspect your password was compromised. "
"Disable it to keep your current API key active after changing your password."
),
required=False,
initial=True, # ← secure-by-default
)The form's save() now calls reset_api_token(self.user) when the box is checked. The default-on choice is the right call: most users mentally model "change password" as "rotate everything", and the help text gives the small minority of users with long-running automation a documented opt-out instead of a silent failure.
The reset_api_key view (manual rotation) was also refactored to use the new helper, eliminating the duplicate Token.objects.filter(...).delete(); Token.objects.create(...) pattern across the codebase.
The release-notes line that ships with the fix:
Password updates now regenerate your personal API key by default.
That single sentence is what users read, and it accurately states the new behaviour without alarming them about the bug.
- 2026-04-17 — Fix PR (#19057) merged to
main. - 2026-04 — Patched release
5.17.1published. - 2026-04-30 — GHSA-6j8j-4qp3-36p2 published; CVE-2026-41519 assigned.
- "Change password" is a security action; everything that grants access from the same identity should rotate with it. Sessions, persistent API tokens, OAuth refresh tokens, "remember me" cookies, application passwords, recovery codes — if any of those survive a password change, the user's mental model is wrong about what happened.
- DRF
authtokenis fine for low-stakes APIs and a footgun for high-stakes ones. It has no expiry and no scoping. If the credential it produces is sensitive, the application has to build its own rotation hooks. Weblate now has those; many DRF-based projects don't. - Secure-by-default opt-out beats secure opt-in. The fix could have shipped as "tick this box if you also want to rotate your API key" — that would have left the same hole open for everyone who didn't realise the box existed. Defaulting to on with a clearly-explained opt-out is the right shape: the safe path is the no-thought path.
- Symmetry between auth credentials matters. Browser sessions and API tokens authenticate the same identity to the same backend; treating them differently in lifecycle code creates exactly this kind of cross-channel survival bug. A useful audit pattern: grep your codebase for every site that calls
cycle_session_keys()(or your framework's equivalent) and ask "what other credential of this user lives elsewhere?".
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-41519
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-41519
- GHSA: https://github.com/WeblateOrg/weblate/security/advisories/GHSA-6j8j-4qp3-36p2
- CWE-613: https://cwe.mitre.org/data/definitions/613.html
- Fix PR: WeblateOrg/weblate#19057
- Upstream project: https://github.com/WeblateOrg/weblate
- DRF token authentication: https://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication