Skip to content

Security

Accounts is the ledger of record, so the controls that matter are the ones that decide who may touch it. This page is for the administrator evaluating the product and for whoever runs it afterwards: how access is checked, what the server does when its configuration is incomplete, how sign-in is protected, and what the server will and will not do on a user’s behalf.

The day-to-day work of authoring roles is on Roles & permissions and under Settings → Roles & permissions in the app. This page is about the enforcement behind it.

Every request to a data route passes through three things in order: the bearer token is verified, the audit trail hooks the request so a successful change is recorded, and the permission gate runs. The gate is mounted once, ahead of the whole API, rather than declared route by route.

That direction is the point. A permission system that has to be remembered on each new endpoint fails open on the endpoint somebody forgot — and an open door is the failure nobody notices. Here a route added tomorrow is gated the moment it exists, and the developer’s work is to grant access rather than to remember to withhold it.

How a permission is worked out. The gate reads the first segment of the path — /invoices/…, /journal-entries/… — as the route group, and the HTTP method as the action. A read needs <group>:read; anything that changes data needs <group>:write. Because it keys off the group and not the exact path, a new sub-route under an existing group (a new invoice action, say) is covered without anyone touching the permission list.

A few rules make role design behave the way people expect:

  • Roles grant, they never restrict. A user’s access is the union of every role they hold, so someone who is both Accountant and Approver can do both jobs.
  • A write grant implies the matching read. You cannot sensibly edit what you may not see, and forcing both keys to be listed is how a permission set ends up subtly wrong.
  • all bypasses everything. It is what the built-in Admin role carries, so Admin is never the role that hits an unexpected 403.
  • An assignment can expire. A grant with an end date stops counting the moment it passes — useful for contractors and leave cover — and deactivating either the role or the assignment takes effect immediately.

The permission list is derived, not hand-written. The catalogue the role editor offers is generated from the routes the server actually mounted, so it cannot drift out of step with them, and the server refuses a role that names a permission it has never heard of. It is the same list the web app fetches to decide which menu entries to show — but that is a courtesy to the user, not the boundary. Hiding a button is manners; the API is what refuses the call.

A few endpoints authenticate differently by design. Sign-in cannot be gated by the key that sits behind it, and a small number of surfaces are covered by a control of their own rather than by a role. The one you will meet is the customer portal, which authenticates a single contact with its own token — that model is described on the Portal page. Everything that serves the books is behind the gate.

Enforcement is controlled by one deployment setting, RBAC_ENFORCED:

RBAC_ENFORCED Behaviour
Unset (the normal case) Enforced in production. Warn-only in development and test.
true Enforced, in any environment.
false Warn-only, in any environment.

Enforced means a request without the permission is refused with a 403 and told which permission it lacked. Warn-only means the server records what it would have denied and lets the request through, so the gap is visible before it is fatal.

The default is the part worth reading twice. A stock production deploy that never sets the flag fails closed. Warn-only in production has to be a deliberate RBAC_ENFORCED=false — it exists as a migration window for a box that has not yet had its roles assigned, not as a state you can arrive in by forgetting an environment variable.

Because a server in warn-only mode is not actually enforcing anything, the app says so rather than pretending: Settings → Roles & permissions shows a banner reading “Permissions are not being enforced” whenever the server reports warn-only, and explains that roles authored there are ready but grant and withhold nothing until enforcement is switched on. Turn it on once your roles are assigned.

You cannot delete your way out of administration. Editing a role, deleting one, or taking one away from somebody is checked against the state it would leave behind: if somebody could administer roles before the change and nobody could afterwards, it is refused, because there is no way back from that without direct database access. That covers the case people actually hit — an administrator removing their own last admin role. Built-in system roles cannot be renamed or deleted, and a role that people still hold cannot be deleted until they are moved off it — deleting it would strip access they would have no idea they had lost.

  • Failures are counted on the account. After five consecutive failed sign-ins the account locks for fifteen minutes, and further attempts are refused with 429 ACCOUNT_LOCKED. Both thresholds are deployment settings.
  • The lock is checked before the password, so a locked account cannot be probed even with the correct credentials — the whole point of a lockout. A clean sign-in clears both the counter and the lock, and the counter starts fresh after a lock expires.
  • A failed sign-in never says which part was wrong. An unknown address, a wrong password and a deactivated user all get the same answer, so the sign-in form cannot be used to find out who has an account.
  • Passwords are stored as bcrypt hashes, never in a recoverable form, and the sign-in request body is deliberately excluded from the audit trail.
  • Changing your password signs out every other session and tells you how many, so a password change is a real revocation rather than a formality. The new password must differ from the current one, and a change refused because the current password was wrong is itself recorded in the audit trail, marked as a failure.

Successful sign-ins and sign-outs are logged with the session, the originating IP address and the user agent — see Audit trail.

Access tokens are JSON Web Tokens signed with HMAC-SHA256, and the algorithm is pinned on both signing and verification. A token that names a different algorithm in its own header is not honoured — the verifier decides, not the token. The signing secret has no built-in default: the server refuses to start without one, so an unconfigured deployment cannot come up holding a known key.

  • An access token lasts an hour by default; the session and its refresh token last seven days. Both lifetimes are deployment settings.
  • Signing out ends the session, and the access token you were carrying is revoked immediately — its hash is recorded until the token’s natural expiry and checked on every subsequent request. Only the hash is stored, never the token itself. Because the session is closed at the same moment, the refresh token stops working too.
  • Refresh tokens are not rotated on use: the token you present is the one you get back, valid until the session expires or is signed out. Treat a refresh token as a credential with the session’s full lifetime.
  • Tenancy comes from the token. Every data route scopes its queries to the company in the verified token, so one workspace’s books are never reachable from another’s session. A convenience header exists to name a company during local development; it is disabled in production builds, where the token is the only source of tenancy.

The call a webhook action in workflows is the one place where an address someone typed into the app decides which host the server connects to. A workflow author is a tenant user, not an operator of your network, so that destination is checked before the request is made.

  • Only http and https destinations are accepted.
  • The destination must be a public address. Loopback, private, link-local and cloud-metadata addresses are refused, and a hostname is resolved first — every answer it resolves to must be public, not merely the first.
  • The call has a hard five-second timeout. A refusal, a timeout, a network error or a non-2xx response is recorded as a failed step and follows the workflow’s own failure handling.
  • The step record keeps the HTTP status and the destination without its query string, so a token passed as a query parameter is never written to the log. Request and response headers are never recorded at all.

The server’s one other outbound integration is email. Sending an invoice or a statement, the dunning reminders and the workflow send an email action all go through a single transport, so there is one thing to configure and one place the sends are logged. In production that transport is Azure Communication Services, switched on with EMAIL_DRIVER=acs and given a connection string and a verified sender address.

The default driver delivers nothing. Left unset, the server records that a send happened and stops there — deliberately, so that a development checkout or a test run is never one copied environment file away from emailing your real customers. A deployment that should actually send has to opt in. If you set up Accounts and no invoice ever arrives, this is the first setting to check.

A workflow author chooses the recipients of the send an email action, so it carries the same shape of trust as the webhook one — a tenant user deciding where the server sends. Give workflow authoring to the same people you would trust with the webhook action.

The remaining outbound calls are not user-directed: the optional exchange-rate feed goes to a fixed public endpoint, and the organization details a PDF is branded from are read-only in the app. Accounts does not connect to bank feeds or payment gateways.

The CSV a report downloads is protected against spreadsheet formula injection. A cell whose text would be executed as a formula when the file is opened in Excel or LibreOffice — a contact name someone entered starting with =, for instance — is written as text rather than as something the spreadsheet will run. Values containing commas, quotes or line breaks are quoted properly so columns cannot shift, and empty values stay empty rather than becoming the word “null”.

Negative amounts are the deliberate exception: a leading minus sign is left alone when the value is a plain number, because a financial export whose negatives stopped being numbers would be worse than useless. Anything else that starts with a minus is treated as text.

The export always serialises the exact rows the report already returned, in the same order — the number you see is the number you get. See Reports → Export for which reports offer CSV.

What the server says when a request is wrong

Section titled “What the server says when a request is wrong”

A malformed or contradictory request gets an answer, not a crash. A broken identifier in a URL, an impossible date, a duplicate, a reference to a record that does not exist, or a value the ledger’s own rules forbid each come back as a specific 4xx with a code a client can act on:

Situation Response
A value in the request is malformed 400 INVALID_VALUE
A date that is not a real calendar date 400 INVALID_DATE
A record with those details already exists 409 DUPLICATE
A referenced record does not exist 422 INVALID_REFERENCE
A value the rules do not allow 422 CONSTRAINT_VIOLATION
Request body larger than 5 MB 413 PAYLOAD_TOO_LARGE
Uploaded file larger than 15 MB 413 FILE_TOO_LARGE
Request body is not valid JSON 400 INVALID_JSON

These messages are deliberately generic. They say what kind of thing went wrong and never name the table, column or constraint involved, so the database schema is not disclosed through error text. Anything the server does not recognise as the caller’s fault stays a 500, and in production a 500 carries a plain “Internal server error” rather than the underlying fault — the detail goes to the server log, where an operator can read it and a stranger cannot.

Setting What it does If you leave it unset
JWT_SECRET Signs and verifies access tokens The server refuses to start
RBAC_ENFORCED Whether a missing permission denies or only warns Enforced in production, warn-only elsewhere
CORS_ORIGINS Comma-separated browser origins allowed to call the API Production allows same-origin only — it never falls back to reflecting any origin
LOGIN_MAX_ATTEMPTS Consecutive failures before an account locks 5
LOGIN_LOCKOUT_MINUTES How long the lock holds 15
JWT_EXPIRES_IN Access-token lifetime 1 hour
REFRESH_EXPIRES_IN Session and refresh-token lifetime 7 days
EMAIL_DRIVER Set to acs to send through Azure Communication Services Nothing is delivered — sends are recorded only
EMAIL_CONNECTION_STRING The sending account, when EMAIL_DRIVER=acs Every send fails — the transport cannot be built
EMAIL_SENDER The verified From address, when EMAIL_DRIVER=acs Every send fails rather than going out unattributed

Standard HTTP security headers are applied to every response, and the API is intended to be served over TLS.

Beyond the settings, three habits are worth adopting:

  • Give each role the least it needs. Reserve all for the people who genuinely administer the workspace; the built-in Accountant role is a better starting point than Admin for everyone else.
  • Review assignments when people change teams. An assignment stays until it is removed, so use the expiry date on temporary grants rather than trusting yourself to remember.
  • Turn enforcement on before you load real books, and check the Roles screen is not showing the warn-only banner.
  • Roles & permissions — how the role model works across the suite, and where it differs product by product.
  • Audit trail — the append-only record of who changed what, including sign-ins, sign-outs and password changes.
  • Platform & settings — the approval processes and workflows the controls above sit around.
  • Portal — the read-only, token-scoped view a contact gets, and why it is outside the role model.
  • Implementation verdict — an independent point-in-time assessment, including where the seeded grants stand.