In April 2026, I reported a scoped API token authorization issue in Vikunja, the open-source task and project management platform. The public GitHub advisory is GHSA-v479-vf79-mg83, GitHub assigned CVE-2026-40103, and the advisory lists patched version 2.3.0.
This was not an unauthenticated issue, and it was not a full privilege escalation beyond the token owner's existing project rights. The affected token still had to be created by a user who could already update the target project. But it was still a real security boundary break inside Vikunja's scoped token model: a token granted projects.background could successfully delete a project background, while a token granted the apparently more specific projects.background_delete scope was rejected.
That mismatch matters because scoped tokens exist to narrow delegated access for integrations and automation. If the token model says "this token can read background state" but the runtime path lets it delete background state, the least-privilege boundary is no longer reliable.

Background
Vikunja lets users create API tokens with explicit scoped permissions. The token model is documented directly in the code and API flow: available token permissions are exposed via /api/v1/routes, token creation validates requested permissions against that route inventory, and request-time authorization checks whether the presented token can use the current route.
That design is sensible. If it works correctly, it gives users a way to issue narrower credentials to scripts, integrations, and automation without handing those systems the full power of a normal user session.
That also means the token scope layer is a real security boundary. A scoped token does not need to grant more power than the full user account to create a security issue. If it can perform an action outside its declared scope, the delegated credential model is broken.
What I Validated
I kept the finding narrow and validated only one concrete route family:
GET/api/v1/projects/:project/backgroundDELETE/api/v1/projects/:project/background
On the commit I reviewed, Vikunja exposed two distinct token permissions for those routes:
projects.backgroundprojects.background_delete
But the runtime authorization decision did not line up with that inventory.
In the validated case:
- a token with only
{"projects":["background"]}could successfully callDELETE /api/v1/projects/:project/background - a token with only
{"projects":["background_delete"]}was rejected with401 Unauthorized
That is the full public claim in this writeup. The same bug class may affect additional routes, but I am intentionally keeping this post limited to the background case because that is the case I independently validated end to end.
Why I Considered This a Security Issue
The obvious pushback is: if the token owner already has project update rights, is this really security-relevant?
I think the answer is yes, but only within the right lense.
This is not a cross-user takeover or an unauthenticated flaw. The token owner still needs enough underlying project access that the delete handler would accept the action. But Vikunja's scoped API tokens are supposed to reduce what delegated credentials can do relative to the full user account. Breaking that guarantee weakens the security model for:
- external integrations
- CI or automation jobs
- scripts handed narrow credentials
- any environment where a token is intentionally safer to expose than a full session or a broader token
That makes this a moderate authorization and integrity issue, not a critical platform compromise.
How The Bug Worked
The issue was a mismatch between how Vikunja registered token-usable routes and how it later mapped live requests back to token permissions.
Step 1: route registration created two distinct permission names
For custom parent subroutes, Vikunja's CollectRoutesForAPITokenUsage() logic groups routes under a parent resource and uses the remaining path segments as the child permission name.
For the project background routes:
GET /api/v1/projects/:project/backgroundbecameprojects.backgroundDELETE /api/v1/projects/:project/backgroundcollided with that same child key and was renamedprojects.background_delete
That part is visible in the registration logic and is also reflected in /api/v1/routes.


Step 2: token creation accepted both permissions
Vikunja validates requested token permissions against the collected route inventory. Because both background and background_delete existed in that inventory, both permission sets were considered valid token scopes at creation time.
That means the bug was not a token-creation validation failure. The token model itself believed both permissions existed and were distinct.
Step 3: runtime authorization reconstructed the wrong permission
At request time, CanDoAPIRoute() tries to map the current request back to a token permission. For the custom parent-subroute fallback path, it rebuilds the child key from the request path:
route = strings.Join(routeParts[1:], "_")For DELETE /api/v1/projects/:project/background, that fallback reconstructs only:
backgroundIt does not re-apply the HTTP method suffix that was added during registration. In the fallback branch, it also authorizes by direct string equality on that reconstructed child key rather than by matching both path and method against the stored RouteDetail.
So the runtime authorization logic accepts:
projects.background
and rejects:
projects.background_delete
for the exact same DELETE request.

Step 4: middleware trusted that wrong decision
The token middleware calls CanDoAPIRoute() before handing the request to the actual route handler.
If CanDoAPIRoute() returns true, the token is accepted as authorized for that route.

Step 5: the delete handler reached a real state-changing path
This matters because the background delete route is not a harmless metadata endpoint.
RemoveProjectBackground():
- checks write rights via
project.CanUpdate(...) - invokes
DeleteBackgroundFileIfExists() - calls
ClearProjectBackground(), which clears the project'sbackground_file_idandbackground_blur_hash
So once the token gate makes the wrong authorization decision, the request reaches a genuine state-changing handler and clears the project's background linkage fields.


Reproduction In Short
The product-level reproduction is simple. The only small environment note is that Vikunja registers these project background routes only when backgrounds.enabled is on. In the reviewed commit, that setting defaults to true, which matched my validation environment.
- Start with a user who can update a project that already has a background.
- Create an API token with only:
{"projects":["background"]}- Send:
DELETE /api/v1/projects/<project_id>/background
Authorization: Bearer <token>- Observe that the request succeeds and the project background is removed.
For comparison, create a token with only:
{"projects":["background_delete"]}and repeat the same request. In the validated case, that token was rejected.
Impact And Limits
I would describe the impact conservatively. Maybe low to medium (CVSS says 4.3):
- real authorization bug: yes
- integrity impact: yes
- unauthenticated or cross-tenant compromise: no
- complete privilege escalation beyond the token owner's underlying project rights: no
The issue weakens the trust model around delegated credentials. A token that appears limited to one capability is able to perform another capability, and the more specific delete permission does not behave as expected.
At the same time, the prerequisites keep the severity lower:
- the attacker needs a valid API token
- the token must belong to a user who already has enough underlying rights that the handler's
CanUpdatecheck passes - the validated impact is limited to project background deletion in the public claim here
A Note On Bug-Class Scope
One nuance is worth calling out.
This is not "every multi-method route in Vikunja is vulnerable." Standard CRUD routes are handled differently and still compare both path and method in the normal authorization branch. The risky area is narrower: custom non-CRUD subroutes that are collected under a parent permission group and later authorized through the fallback child-key reconstruction path.
That narrower framing is one reason I felt comfortable reporting the background case specifically instead of making a broad public claim about the entire route tree.
Disclosure Timeline
- 2026–04–08: I reported the issue through Vikunja's GitHub private advisory flow.
- 2026–04–09: The maintainer accepted the advisory and noted that the same bug class likely affects additional routes.
- 2026–04–09: The advisory was published publicly as
GHSA-v479-vf79-mg83. - 2026–04–10: GitHub assigned
CVE-2026-40103. - 2026–04–13: I verified the public advisory lists patched version
2.3.0.
The maintainer was very responsive and communicative throughout the disclosure process. If the maintainer later publishes a broader advisory or splits additional variants into a separate record, that would be the right place to describe the wider class. This writeup stays intentionally narrower than that.
Takeaways
The main lesson here is simple: if a permission system defines permissions one way, it needs to enforce them the same way.
In this case, Vikunja created separate permission names while registering routes, but the fallback authorization logic later rebuilt permissions using only the request path. That mismatch let a scoped token perform a stronger action than its declared scope suggested.
