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.

GitHub advisory comment assigning CVE-2026-40103.

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/background
  • DELETE /api/v1/projects/:project/background

On the commit I reviewed, Vikunja exposed two distinct token permissions for those routes:

  • projects.background
  • projects.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 call DELETE /api/v1/projects/:project/background
  • a token with only {"projects":["background_delete"]} was rejected with 401 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/background became projects.background
  • DELETE /api/v1/projects/:project/background collided with that same child key and was renamed projects.background_delete

That part is visible in the registration logic and is also reflected in /api/v1/routes.

Vikunja project background GET and DELETE routes in routes.go.

Route collector logic in api_routes.go appending a method suffix on collision.

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.

Fallback logic in CanDoAPIRoute rebuilding the route key and authorizing by string equality.

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.

Token middleware calling CanDoAPIRoute before allowing the request through.

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's background_file_id and background_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.

Handler-side permission check using project.CanUpdate in checkProjectBackgroundWritePermissions. ClearProjectBackground updating background_file_id and background_blur_hash for the project.

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() calls project.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.

Changelog entry for the earlier Require CanUpdate for project background deletion fix.

Validation approach

I validated the issue three ways.

1. Route inventory validation

I confirmed that /api/v1/routes exposes both:

  • background
  • background_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 background and background_delete
  • CanDoAPIRoute() authorizes DELETE /api/v1/projects/:project/background for a token with only projects.background
  • CanDoAPIRoute() rejects the same request for a token with only projects.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"]} received 200 OK for DELETE /api/v1/projects/35/background
  • direct database validation showed the project record's BackgroundFileID became 0
  • a token with only {"projects":["background_delete"]} received 401 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.

Focused local Mage rerun showing the exact test filter command and passing results for pkg/models and pkg/webtests.

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.

  1. Start with a user who can update a project that already has a background.
  2. Create an API token with only:
{"projects":["background"]}
  1. Send:
DELETE /api/v1/projects/<project_id>/background
Authorization: Bearer <token>
  1. 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 CanUpdate check 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.