DEV Community

Mohammadreza Alamnejad
Mohammadreza Alamnejad

Posted on

The security and quality checklist I use before launching a SaaS: multi-tenant isolation, role-based access, idempotency, audit logs

Multi-tenant isolation, role-based access, idempotency and audit logs: the boring controls that decide whether customers can trust your numbers.

Key takeaways

  • Enforce tenant isolation in the data layer, not only in application code. Row-level security (or an equivalent mandatory filter) means one forgotten WHERE tenant_id = ? doesn't leak another customer's data.
  • Authorization is role + scope + ownership. Check who the user is, which branch, warehouse or project they may act on, and whether the target resource is inside that scope, on every sensitive request.
  • Hide data in the server response, not in the UI. A hidden button with the number still in the JSON is a data leak.
  • Every state-changing endpoint that moves money or stock must be idempotent. Double clicks, retries and offline sync must not create a second payment, sale or adjustment.
  • Audit logs should record actor, time, and before/after state, and be append-only for sensitive records.
  • Backups only count once you've restored one.

I'm Mohammadreza Alamnejad, a UI/UX designer and developer and the founder of Arka Studio. My background mixes front-end and product design with security work, and that mix shapes how I ship. Before any multi-tenant SaaS my studio builds goes in front of real users, including our café and restaurant operations platform NABZA, I go through a checklist like the one below.

This is a general checklist, not an audit report of any specific system. Where I mention NABZA, I'm only referring to behaviour that's already described publicly on its site.

1. Multi-tenant isolation

The worst bug a SaaS can ship is one customer seeing another customer's data. In a multi-tenant app, assume that somewhere, someday, a developer will forget a filter. Design so that mistake fails closed.

Checklist

  • [ ] Every tenant-owned table has a non-null tenant_id (or org_id) column.
  • [ ] Isolation is enforced in the database (row-level security, or a mandatory query layer that cannot be bypassed), not just in controllers.
  • [ ] The tenant is derived from the authenticated session, never from a request parameter or header the client controls.
  • [ ] Background jobs, exports, reports and search indexes are tenant-scoped too. These are where isolation usually breaks.
  • [ ] File storage paths and signed URLs include the tenant, and public files are explicitly opted in.
  • [ ] Caches are keyed by tenant. A shared cache key is a cross-tenant leak waiting to happen.
  • [ ] Automated tests log in as tenant A and try to read, update and delete tenant B's resources by ID.

A minimal PostgreSQL row-level security example:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
Enter fullscreen mode Exit fullscreen mode

The application sets app.tenant_id per transaction from the verified session (SET LOCAL), and the application's database role must not be the table owner or a superuser, otherwise policies can be bypassed. NABZA's public security page describes the same principle: every row is locked to its organization with row-level security, so one organization's request doesn't see another's data.

2. Role-based access that's actually about scope

"Admin / manager / staff" is a starting point, not an authorization model. Real businesses have scope: a shift supervisor in branch 1 should not approve purchases for branch 2, and a storekeeper should only move stock out of the warehouses they are responsible for.

Checklist

  • [ ] Permissions are granular (e.g. inventory.transfer.approve), and roles are just bundles of them.
  • [ ] Every sensitive action checks role + scope + target: may this user do this action, in this branch, on this specific resource?
  • [ ] For transfers and similar flows, check both the source and the destination scope.
  • [ ] Users cannot grant themselves more permissions, or grant permissions they don't hold.
  • [ ] The last owner of an organization cannot be removed or demoted.
  • [ ] Field-level restrictions (e.g. financial figures) are applied when building the response, not with CSS or conditional rendering.
  • [ ] Every "hidden" field has been checked in the raw API response using the role that shouldn't see it.
  • [ ] Object IDs aren't trusted: check that GET /orders/123 belongs to the caller's tenant and scope (IDOR tests).
  • [ ] Privileged support access is limited to the specific ticket or conversation, and is logged.

The last owner rule and "no self-escalation" sound obvious, but they are among the first things I test, because they're easy to miss when roles are editable in the UI.

3. Idempotency: one intent, one effect

Users double-click. Mobile networks drop requests after the server has processed them. Clients retry. Offline queues replay. If an endpoint creates a payment, sale, stock movement or invoice, it has to be safe to call twice.

Checklist

  • [ ] Every state-changing request from the client carries an idempotency key (a UUID generated once per user intent, not per HTTP attempt).
  • [ ] The server stores the key with a hash of the request body and the result, inside the same transaction as the side effect.
  • [ ] A replay with the same key and body returns the original result; the same key with a different body is rejected.
  • [ ] State transitions are guarded (UPDATE ... WHERE status = 'ready'), so completing an order twice cannot convert it to a sale twice.
  • [ ] Payments are validated against the outstanding balance: duplicates and over-payments are rejected.
  • [ ] Offline sync submits each queued item exactly once and re-checks business rules (like price) on the server instead of trusting the cached client state.
  • [ ] Imports are safe to re-run: uploading the same opening-stock file twice doesn't double the stock.
  • [ ] Outgoing webhooks include an event ID so receivers can de-duplicate, and retries are bounded.

A simple schema:

CREATE TABLE idempotency_keys (
  tenant_id    uuid        NOT NULL,
  key          uuid        NOT NULL,
  request_hash text        NOT NULL,
  response     jsonb,
  created_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, key)
);
Enter fullscreen mode Exit fullscreen mode

Insert the key first, do the work, store the response, commit. A concurrent duplicate hits the primary key and waits or fails instead of doing the work twice.

On NABZA's public pages this shows up as product behaviour: an order is converted to a sale "once and only once, even with a second click", the kitchen ticket prints once even with two devices open, and an offline order that saw a price change is held for review instead of silently repriced. From the user's point of view, idempotency is a UX feature.

4. Audit logs that can answer "who changed this?"

When numbers don't match at the end of the day, the first question is always "who changed what, and when?" If your system can't answer, users fill the gap with suspicion.

Checklist

  • [ ] Important events record actor, tenant, timestamp, action, target, and before/after state.
  • [ ] Sensitive records (payments, stock adjustments, approved documents, published standards) are append-only. Corrections are new entries that reference the original, not edits.
  • [ ] The audit table can't be updated or deleted by the application role.
  • [ ] Manager overrides are labelled as such (e.g. "closed by manager") rather than looking like the employee did it.
  • [ ] Audit entries don't contain secrets, full tokens, passwords or unnecessary personal data. Mask phone numbers where full values aren't needed.
  • [ ] There's a UI to read the history. An audit log nobody can see doesn't build trust.
CREATE TABLE audit_events (
  id         bigserial   PRIMARY KEY,
  tenant_id  uuid        NOT NULL,
  actor_id   uuid,
  action     text        NOT NULL,
  target     text        NOT NULL,
  before     jsonb,
  after      jsonb,
  created_at timestamptz NOT NULL DEFAULT now()
);
REVOKE UPDATE, DELETE ON audit_events FROM app_role;
Enter fullscreen mode Exit fullscreen mode

5. The rest of the pre-launch list

These don't fit the four headings above, but I check every one of them before launch.

Authentication and sessions

  • [ ] One-time codes (SMS/email) expire quickly, are single-use and are rate-limited per number and per IP.
  • [ ] Session cookies are HttpOnly, Secure, and SameSite; sessions are revoked on password reset and role change.
  • [ ] API keys are scoped (e.g. to one branch), hashed at rest, and shown only once at creation.

Input and outbound requests

  • [ ] Uploaded files are validated by content, not extension, then re-encoded (e.g. images converted to WebP), and only published assets are public.
  • [ ] Webhook destinations are validated against SSRF: no private, internal or loopback addresses, and no following redirects into them.
  • [ ] Outbound webhooks are signed (HMAC) with a per-endpoint secret and a timestamp to prevent replay.
  • [ ] Prices, discounts and totals from a client (like a guest's phone ordering from a QR menu) are never trusted; the server recalculates.

Data honesty

  • [ ] Missing data is shown as "unknown", never as zero. A cost of 0 and an unknown cost are different facts.
  • [ ] Features that depend on a third-party service that isn't configured are labelled as unavailable rather than faked.

Operations

  • [ ] Backups are encrypted, run daily and before each release, include an off-server copy, and have been test-restored.
  • [ ] Secrets live in environment or a secret manager, never in the repository; the repo history has been scanned.
  • [ ] Security headers (CSP, HSTS, X-Content-Type-Options, frame-ancestors) are set and tested.
  • [ ] Dependency and container scans are part of CI.
  • [ ] Error responses don't leak stack traces, SQL or internal hostnames.
  • [ ] Logs and alerts exist for repeated authorization failures and unusual activity.

How I run the checklist

I don't treat this as a document to tick once. Each item becomes either an automated test (cross-tenant access, idempotent replay, permission escalation attempts) or a release gate in CI. The manual part is a short adversarial session: log in as the least-privileged role, open the browser's network tab, and try to see, change or repeat something you shouldn't be able to. You'll be surprised how much a single afternoon of this catches.

Security in a SaaS isn't a feature page. It's whether a business owner can open the end-of-day report and believe it.


About the author

Mohammadreza Alamnejad is a UI/UX Designer & Developer, Founder of Arka Studio, a UI/UX and web/app design and development studio. His prior experience includes work on large Iranian products such as Achareh, Filimo, Bale and Digikala as a developer, UI/UX designer, front-end developer and in security. Read more on his founder page at Arka Studio or see NABZA, the café and restaurant operations platform his studio designed and built.

Top comments (0)