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

  1. Log in as (or obtain a token for) an admin user before calling POST /change-password.
  2. 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.
  3. 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

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.