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; the advisory lists 2.3.0 as the patched version.
The fix shipped in go-vikunja/vikunja#2584 (patch commit 6a0f39b252a81fa4b19dc56dc889183acc9225ae).
Advisory severity: Moderate (CVSS 4.3).
I validated the issue against commit c5450fb55f5192508638cbb3a6956438452a712e.
Authentication was required, and the issue did not grant privilege 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. Even so, it was a 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.
Scoped tokens exist to narrow delegated access for integrations and automation. If the token model says a 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 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
A comment on the advisory notes that the same bug class may affect additional routes.
Why I considered this a security issue
The obvious pushback is: if the token owner already has project update rights, is this security-relevant?
I think the answer is yes.
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. 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
The actual break happened later.
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:
background
It 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 state-changing path
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 state-changing handler and clears the project's background linkage fields.

Why this was not just the old background bug again
Vikunja's changelog already referenced an earlier fix requiring CanUpdate for project background deletion. That older issue was about ordinary user permissions on the handler side.
In current code, that handler-side fix appears to be present:
checkProjectBackgroundWritePermissions()callsproject.CanUpdate(...)
That is why I treated this as a distinct issue rather than a rewording of the older one. The user-session authorization check looked correct. The flaw was in scoped API token enforcement before the request reached the handler.

Validation approach
I validated the issue three ways.
1. Route inventory validation
I confirmed that /api/v1/routes exposes both:
backgroundbackground_delete
This shows the system itself treats those permissions as distinct, valid token scopes.
2. Permission matcher validation
I added a focused local test that directly exercised the route registration and CanDoAPIRoute() logic.
That test showed:
- the route inventory contains both
backgroundandbackground_delete CanDoAPIRoute()authorizesDELETE /api/v1/projects/:project/backgroundfor a token with onlyprojects.backgroundCanDoAPIRoute()rejects the same request for a token with onlyprojects.background_delete
3. End-to-end web validation
I then validated the full request path through the actual Echo route stack and token middleware.
Using a scoped API token owned by a user who could update project 35, I confirmed that:
- a token with only
{"projects":["background"]}received200 OKforDELETE /api/v1/projects/35/background - direct database validation showed the project record's
BackgroundFileIDbecame0 - a token with only
{"projects":["background_delete"]}received401 Unauthorized
That last step is important because it proves the bug is not only theoretical or limited to a helper function. It is reachable through the API authorization path.

Reproduction in short
One setup note: 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
- Authorization bug: yes
- Meaningful 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.
This maps to CWE-863 (Incorrect Authorization).[1]
At the same time, the prerequisites keep the severity moderate:
- 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 here is limited to project background deletion
[1] The published CVE record carries CWE-836, while the advisory maps CWE-863 (Incorrect Authorization). CWE-863 is the accurate category used here.
A note on bug-class scope
Not every multi-method route 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.
Disclosure timeline
- 2026-04-08: I reported the issue through Vikunja's GitHub private advisory flow.
- 2026-04-09: The maintainer accepted the advisory; a comment on the advisory notes the same bug class may affect 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.
Takeaways
If a permission system derives identifiers during route registration, it needs to use the same identity model during runtime authorization.
In this case, Vikunja created method-disambiguated permission names during route collection, but the fallback authorization path later reconstructed permissions from the request path alone. That difference was enough to let a scoped token act like a stronger credential than it appeared to be.
Least-privilege token models deserve the same rigor as primary user-session authorization.