actualbudget/actual · warning
forbidden
forbidden
Error message
permission-not-found
What it means
HTTP 403 from POST /users (create user). The request passed `validateSessionMiddleware`, but `isAdmin(res.locals.user_id)` is false, so user creation — an admin-only operation — is rejected with `reason:'forbidden', details:'permission-not-found'`.
Source
Thrown at packages/sync-server/src/app-admin.js:49
// (wont-fix). Actual's multi-user/OpenID feature is intended for friends &
// family setups, not SaaS, so the attack surface is low. The endpoint is also
// used in the budget ownership transfer flow, where neither the current nor the
// target user is necessarily an admin — adding isAdmin would break that flow
// without a substantial refactor.
app.get('/users/', validateSessionMiddleware, (req, res) => {
const users = UserService.getAllUsers();
res.json(
users.map(u => ({
...u,
owner: u.owner === 1,
enabled: u.enabled === 1,
})),
);
});
app.post('/users', validateSessionMiddleware, async (req, res) => {
if (!isAdmin(res.locals.user_id)) {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
return;
}
const { userName, role, displayName, enabled } = req.body || {};
if (!userName || !role) {
res.status(400).send({
status: 'error',
reason: `${!userName ? 'user-cant-be-empty' : 'role-cant-be-empty'}`,
details: `${!userName ? 'Username' : 'Role'} cannot be empty`,
});
return;
}
View on GitHub (pinned to d4334cb6e6)
Solutions
- Call POST /users with an admin user's token.
- Grant the admin role to the intended user via an existing admin session or directly in the account database.
- Bootstrap the first owner/admin user if no admin exists (fresh multi-user setup), then use that account for administration.
Example fix
// before
await api.post('/users', payload, { headers: { 'X-ACTUAL-TOKEN': memberToken } });
// after
await api.post('/users', payload, { headers: { 'X-ACTUAL-TOKEN': adminToken } }); Defensive patterns
Strategy: validation
Validate before calling
const v = await get('/validate', { headers: authHeaders(token) });
if (v.data.data.permission !== 'admin') throw new Error('User creation requires an admin session'); Type guard
function isAdminSession(session) {
return session != null && session.permission === 'admin';
} Try / catch
try {
await post('/users', payload, { headers: authHeaders(token) });
} catch (e) {
if (e.response?.status === 403 && e.response.data.details === 'permission-not-found') {
throw new AdminRequiredError('Use an admin token to create users');
}
throw e;
} Prevention
- Verify /validate permission === 'admin' before any user-management call
- Keep the bootstrap owner account dedicated to administration
- If a role downgrade breaks automation, re-provision an admin token
When it happens
Trigger: An authenticated non-admin user calls POST /users to create a new account. Same guard also applies to PATCH /users and DELETE /users.
Common situations: Automation using a service token belonging to a regular user; admin role revoked from the account the script was set up with; OpenID-managed users where nobody was granted the admin role.
Related errors
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/545255e6119c5343.
Report an issue: GitHub.