CVE-2026-49229
Actual: Disabled OpenID users keep access through existing session tokens
Actual is a local-first personal finance app. Prior to 26.6.0, in OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity, while existing Actual session tokens for the disabled user remain valid. The shared session validation path accepts any existing token row that has not expired without checking whether the associated user is still enabled, allowing a disabled user to continue calling authenticated server endpoints. This issue is fixed in version 26.6.0.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- Low
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- High
- Availability
- Low
Exploit likelihood
What each source reports
CVE List Score used
8.3
Actual: Disabled OpenID users keep access through existing session tokens
Actual is a local-first personal finance app. Prior to 26.6.0, in OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity, while existing Actual session tokens for the disabled user remain valid. The shared session validation path accepts any existing token row that has not expired without checking whether the associated user is still enabled, allowing a disabled user to continue calling authenticated server endpoints. This issue is fixed in version 26.6.0.
- Record
CVE-2026-49229- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Weakness
- CWE-613
- Updated
- 2026-11-02
EUVDEUVD-2026-42105
8.3
Actual is a local-first personal finance app. Prior to 26.6.0, in OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity, while existing Actual session tokens for the disabled user remain valid. The shared session validation path accepts any existing token row that has not expired without checking whether the associated user is still enabled, allowing a disabled user to continue calling authenticated server endpoints. This issue is fixed in version 26.6.0.
- Record
EUVD-2026-42105- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Updated
- 2026-11-02
GitHub AdvisoriesGHSA-cq9c-6w48-qmfg
8.3
@actual-app/sync-server: Disabled OpenID users keep access through existing session tokens
Summary
In OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity. Existing Actual session tokens for the disabled user remain valid, so the user can continue calling authenticated server endpoints after an administrator has disabled the account.
Details
The disabled-user check is present during OpenID login finalization. Existing users are only accepted when the matching row has enabled = 1, and a disabled row causes the OpenID grant to fail before a new session token is created.
// packages/sync-server/src/accounts/openid.ts:284-291
const { id: userIdFromDb, display_name: displayName } =
accountDb.first(
'SELECT id, display_name FROM users WHERE user_name = ? and enabled = 1',
[identity],
) || {};
if (userIdFromDb == null) {
throw new Error('openid-grant-failed');
}
The shared session validation path does not perform the same enabled-user check. It accepts any existing token row that has not expired, then returns the session object to every route protected by validateSessionMiddleware.
// packages/sync-server/src/util/validate-user.ts:10-41
export function validateSession(req: Request, res: Response) {
let { token } = req.body || {};
if (!token) {
token = req.headers['x-actual-token'];
}
const session = getSession(token);
...
return session;
}
This means account disablement and session authorization diverge:
OpenID login path: users.enabled must be 1
Existing session path: token exists and is not expired; users.enabled is not checked
The default token expiration setting is never, so this is not just a short race after disablement on default deployments.
// packages/sync-server/src/load-config.js:260-264
token_expiration: {
doc: 'Token expiration time.',
format: 'tokenExpiration',
default: 'never',
env: 'ACTUAL_TOKEN_EXPIRATION',
},
Admins can change a user's enabled state through the user update route, but that update does not delete the user's existing sessions. After the update, the old token still satisfies validateSession.
// packages/sync-server/src/app-admin.js:91-101
app.patch('/users', validateSessionMiddleware, async (req, res) => {
if (!isAdmin(res.locals.user_id)) {
...
}
const { id, userName, role, displayName, enabled } = req.body || {};
// packages/sync-server/src/services/user-service.ts:98-102
getAccountDb().mutate(
'UPDATE users SET user_name = ?, display_name = ?, enabled = ?, role = ? WHERE id = ?',
[userName, displayName, enabled, roleId, userId],
);
Authenticated server features then continue to trust that session. For example, the sync API installs validateSessionMiddleware for the whole router, so a disabled user can keep using any sync operation that their still-valid session and existing file ownership/access allow.
// packages/sync-server/src/app-sync.ts:37-39
const app = express();
app.use(validateSessionMiddleware);
app.use(errorMiddleware);
This is distinct from the previously published cross-user sync authorization issue: the attacker does not need to access another user's file ID. The bypass is that a disabled user's own session remains authorized after account disablement.
PoC
- Run an Actual Sync Server in OpenID multi-user mode with
@actual-app/sync-server26.5.0. Use the default token expiration setting, or any setting where the token has not expired yet. - Log in as a non-admin OpenID user and save the returned Actual session token.
- As an admin, disable that same user through
PATCH /admin/usersby sendingenabled: false. - Reuse the old token against a protected endpoint.
Example success check:
curl -s https://actual.example.com/account/validate \
-H 'X-Actual-Token: <disabled_user_existing_token>'
Expected result on the affected code path: the request is still treated as authenticated and returns the disabled user's account/session information instead of 401…
- Record
GHSA-cq9c-6w48-qmfg- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Weakness
- CWE-613
- Updated
- 2026-11-02
NVD
8.3
Actual is a local-first personal finance app. Prior to 26.6.0, in OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity, while existing Actual session tokens for the disabled user remain valid. The shared session validation path accepts any existing token row that has not expired without checking whether the associated user is still enabled, allowing a disabled user to continue calling authenticated server endpoints. This issue is fixed in version 26.6.0.
- Record
CVE-2026-49229- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Weakness
- CWE-613
- Updated
- 2026-11-02
OSV
8.3
Actual: Disabled OpenID users keep access through existing session tokens
Actual is a local-first personal finance app. Prior to 26.6.0, in OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity, while existing Actual session tokens for the disabled user remain valid. The shared session validation path accepts any existing token row that has not expired without checking whether the associated user is still enabled, allowing a disabled user to continue calling authenticated server endpoints. This issue is fixed in version 26.6.0.
- Record
CVE-2026-49229- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Weakness
- CWE-613
- Updated
- 2026-11-02
OSVGHSA-cq9c-6w48-qmfg
8.3
@actual-app/sync-server: Disabled OpenID users keep access through existing session tokens
Summary
In OpenID multi-user mode, disabling a user only blocks future OpenID login for that identity. Existing Actual session tokens for the disabled user remain valid, so the user can continue calling authenticated server endpoints after an administrator has disabled the account.
Details
The disabled-user check is present during OpenID login finalization. Existing users are only accepted when the matching row has enabled = 1, and a disabled row causes the OpenID grant to fail before a new session token is created.
// packages/sync-server/src/accounts/openid.ts:284-291
const { id: userIdFromDb, display_name: displayName } =
accountDb.first(
'SELECT id, display_name FROM users WHERE user_name = ? and enabled = 1',
[identity],
) || {};
if (userIdFromDb == null) {
throw new Error('openid-grant-failed');
}
The shared session validation path does not perform the same enabled-user check. It accepts any existing token row that has not expired, then returns the session object to every route protected by validateSessionMiddleware.
// packages/sync-server/src/util/validate-user.ts:10-41
export function validateSession(req: Request, res: Response) {
let { token } = req.body || {};
if (!token) {
token = req.headers['x-actual-token'];
}
const session = getSession(token);
...
return session;
}
This means account disablement and session authorization diverge:
OpenID login path: users.enabled must be 1
Existing session path: token exists and is not expired; users.enabled is not checked
The default token expiration setting is never, so this is not just a short race after disablement on default deployments.
// packages/sync-server/src/load-config.js:260-264
token_expiration: {
doc: 'Token expiration time.',
format: 'tokenExpiration',
default: 'never',
env: 'ACTUAL_TOKEN_EXPIRATION',
},
Admins can change a user's enabled state through the user update route, but that update does not delete the user's existing sessions. After the update, the old token still satisfies validateSession.
// packages/sync-server/src/app-admin.js:91-101
app.patch('/users', validateSessionMiddleware, async (req, res) => {
if (!isAdmin(res.locals.user_id)) {
...
}
const { id, userName, role, displayName, enabled } = req.body || {};
// packages/sync-server/src/services/user-service.ts:98-102
getAccountDb().mutate(
'UPDATE users SET user_name = ?, display_name = ?, enabled = ?, role = ? WHERE id = ?',
[userName, displayName, enabled, roleId, userId],
);
Authenticated server features then continue to trust that session. For example, the sync API installs validateSessionMiddleware for the whole router, so a disabled user can keep using any sync operation that their still-valid session and existing file ownership/access allow.
// packages/sync-server/src/app-sync.ts:37-39
const app = express();
app.use(validateSessionMiddleware);
app.use(errorMiddleware);
This is distinct from the previously published cross-user sync authorization issue: the attacker does not need to access another user's file ID. The bypass is that a disabled user's own session remains authorized after account disablement.
PoC
- Run an Actual Sync Server in OpenID multi-user mode with
@actual-app/sync-server26.5.0. Use the default token expiration setting, or any setting where the token has not expired yet. - Log in as a non-admin OpenID user and save the returned Actual session token.
- As an admin, disable that same user through
PATCH /admin/usersby sendingenabled: false. - Reuse the old token against a protected endpoint.
Example success check:
curl -s https://actual.example.com/account/validate \
-H 'X-Actual-Token: <disabled_user_existing_token>'
Expected result on the affected code path: the request is still treated as authenticated and returns the disabled user's account/session information instead of 401…
- Record
GHSA-cq9c-6w48-qmfg- CVSS 3.1
- 8.3 High
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:L - Weakness
- CWE-613
- Updated
- 2026-11-02
Affected products
Affected packages
| Package | Affected | Fixed |
|---|---|---|
@actual-app/sync-servernpm |
through 26.5.2 | 26.6.0 |
@actual-app/sync-servernpm |
before 26.6.0 | 26.6.0 |
History
| Recorded | Changed | Sources |
|---|---|---|
| aliases | EUVD | |
| first indexed | CVE List, GitHub Advisories, NVD, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
In short
CVE-2026-49229 is a high vulnerability (CVSS 8.3) in Actualbudget Actual, published June 22, 2026. No source lists public exploit code. EPSS puts the chance of exploitation in the next 30 days at 0.32%, the 25th percentile. Fixed in @actual-app/sync-server 26.6.0.
References
github.com/actualbudget/actual/security/advisories/GHSA-cq9c-6w48-qmfg
github.com/actualbudget/actual/commit/c8cb8a223a4faf1c2e1dcb0795a79a93f7b19e80
github.com/actualbudget/actual/releases/tag/v26.6.0
github.com/advisories/GHSA-cq9c-6w48-qmfg
github.com/CVEProject/cvelistV5/tree/main/cves/2026/49xxx/CVE-2026-49229.json
nvd.nist.gov/vuln/detail/CVE-2026-49229
github.com/actualbudget/actual
Left out here: the rest of 2 source descriptions. The JSON record has them.