actualbudget/actual · warning
forbidden
forbidden
Error message
permission-not-found
What it means
HTTP 403 from POST /change-password. The session is valid, but the authenticated user is not an admin, so the server refuses the password change with `reason:'forbidden', details:'permission-not-found'`. In Actual's sync-server only admin users may change the server password via this endpoint.
Source
Thrown at packages/sync-server/src/app-account.js:136
tokenRes = await loginWithPassword(req.body.password);
break;
}
const { error, token } = tokenRes;
if (error) {
res.status(400).send({ status: 'error', reason: error });
return;
}
res.send({ status: 'ok', data: { token } });
});
app.post('/change-password', async (req, res) => {
const session = validateSession(req, res);
if (!session) return;
if (!isAdmin(session.user_id)) {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
return;
}
if (session.auth_method !== 'password') {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'password-auth-not-active',
});
return;
}
const { error } = await changePassword(req.body.password);
View on GitHub (pinned to d4334cb6e6)
Solutions
- Log in as (or obtain a token for) an admin user before calling POST /change-password.
- Grant the `admin` role to the user in the account database (via the admin UI or the users table) if they legitimately need this right.
- If a non-admin only needs to change their own credentials, use the identity-provider (OpenID) flow instead of this endpoint.
Example fix
// before: calling with a regular user's token
await api.post('/change-password', { password }, { headers: { 'X-ACTUAL-TOKEN': userToken } });
// after: use an admin session token
await api.post('/change-password', { password }, { headers: { 'X-ACTUAL-TOKEN': adminToken } }); Defensive patterns
Strategy: validation
Validate before calling
// check the session's role before calling admin-only endpoints
const v = await get('/validate', { headers: authHeaders(token) });
if (v.data.data.permission !== 'admin') throw new Error('Admin session required'); Type guard
function isAdminSession(session) {
return session != null && session.permission === 'admin';
} Try / catch
try {
await post('/change-password', { password }, { headers: authHeaders(token) });
} catch (e) {
if (e.response?.status === 403 && e.response.data.details === 'permission-not-found') {
notifyAdminRightsRequired();
} else throw e;
} Prevention
- Gate admin-only UI actions on the `permission` value returned by GET /validate
- Use dedicated admin credentials for automation
- Do not reuse regular-user tokens for privileged operations
When it happens
Trigger: An authenticated non-admin user (or a client acting with their token) calls POST /change-password. Any valid session whose `user_id` fails the `isAdmin(user_id)` check in account-db.
Common situations: Multi-user/OpenID setups where a regular family member's client tries to rotate the server password; scripts that reuse a non-admin token; confusion between the per-user OpenID password flow and the admin server-password flow.
Related errors
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/e35c459268cc4fa4.
Report an issue: GitHub.