Skip to content

Security

The identity work you keep postponing is already in production

ZenHorizon Engineering · 15 July 2025 · 4 min

“We will add SSO after the pilot.” We have heard that sentence in fintech, in health, in a three-person SaaS that already had two enterprise conversations in the pipeline. The pilot then becomes the product. The first big customer asks for Okta. You discover that `user.role` is a string with five unofficial values and a sixth that only exists in a support engineer’s head.

Identity is not a screen. It is the question “who is allowed to do this, on whose data, and can we prove it later?” If you do not answer it in week two, every feature invents a private answer. Those answers will not agree.

The migration later is not a refactor. It is a rewrite of every query that assumed a user was a row with a role column. Budget it now or budget it as a surprise. Those are the options.

Tenancy before features

Decide how a row knows which customer it belongs to. A `tenant_id` on every business table is unfashionable and correct for most products we ship. Fancy row-level magic in the database can come later if you outgrow the obvious model. Starting with “we will infer it from the user” is how you leak one clinic’s appointments into another clinic’s dropdown.

Write a test that fails if a query in the request path can return another tenant’s row. Not a policy PDF. A test. We have caught more than one “impossible” leak that way, usually in a report endpoint someone added on a Friday, usually with a `WHERE` that filtered the pretty list and forgot the export.

Shared-email edge cases show up early if you look. A consultant who works for two hospitals. A founder who also has a personal workspace. If your unique key is email, you will paint yourself into a corner the first time a real person has two memberships. Unique on `(tenant_id, email)` or, better, treat identity as a person and membership as a join.

Roles are not a dropdown with three options

Admin / user / viewer lasts until the first operations hire. Then you need “can refund but cannot export”. Then a partner user who can see a subset. If you encode that as booleans on the user row, you will spend a quarter migrating to permissions you should have named on day one.

We keep a short list of capabilities — `payout.create`, `member.invite`, `record.export` — and map roles onto that list per tenant. It looks like overkill in week two. It looks obvious in month eight.

Do not let the UI be the authorization layer. A hidden button is not a control. The API checks the capability. The UI only decides whether to show the button. We still find codebases where the dangerous route is “secret” and the mobile app just never linked to it.

SSO is a product surface

  • Use a specialist (Cognito, Auth0, Clerk, WorkOS, Keycloak if you must self-host) unless identity is your actual product.
  • Plan for SCIM or at least a CSV invite that does not embarrass you in front of an IT admin.
  • Session lifetime, step-up for money movement, and logout across devices are part of the first design, not a hardening sprint.
  • Service accounts for your own jobs. Do not let a cron inherit a human’s token “for now”.
  • Map on the IdP subject, not on email. Emails get reassigned. Subjects are the point of SSO.

Just-in-time provisioning will create a user the first time they log in. That is convenient and incomplete. Decide what role they get when the IdP says nothing useful. “Admin because they arrived first” is a story we have had to unwind with a customer on the phone.

Audit logs belong next to the capability, not in a separate “compliance project”. Who exported the CSV. Who changed a role. Who overrode a price. If you add that after a customer asks, you will backfill fiction.

A capability list that has survived contact

For a typical B2B product we start with something like: `member.invite`, `member.role.update`, `record.read`, `record.export`, `payout.create`, `payout.approve`, `settings.sso`. Refunds and exports are never the same capability. Approving your own payout is never allowed, even for an admin, unless the tenant is a single-person shop and they have said so in writing.

The list stays short on purpose. If you have forty capabilities in month two, you are encoding screens, not risk. Add a capability when a new kind of harm appears, not when a new button does.

Put the list in the repo next to the policy check. When a PR adds a route, it names the capability or it names “public”. “Public” should make you nervous. Nervous is useful.

What “later” actually costs

We have done the retrofit. It is never the login screen. It is every list endpoint, every webhook, every background job that loaded “all the rows for the user” and meant “all the rows”. It is the support tool that used a shared god account. It is the BI extract that had no tenant filter because “it’s internal”.

Postpone the pretty settings page if you have to. Do not postpone the model. It is already running. You just have not admitted it.

Build the next chapter of your product with a team that treats engineering as a craft.

Tell us what you are shipping. We will respond with a clear next step — not a generic brochure.