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.

None

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/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

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/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.

None
None

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:

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.

None

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.

None

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

None
None

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.

  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

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 CanUpdate check 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.

None