Skip to main content
INS // Insights

SaaS Tenant Provisioning AWS That Isolates Data

Updated September 2026 · 7 min read

SaaS tenant provisioning AWS is the path that runs when a customer signs up and must receive an isolated place to work. The job creates the tenant, isolates that tenant's data from every other tenant, seeds the defaults the product promised, and marks the tenant ready only after a probe proves the isolation. A row inserted in an admin database with status "active" while the schema is empty is not provisioning. It is a support ticket waiting for morning.

Rutagon builds that path on AWS for product teams that have outgrown a manual SQL script. The reference shape comes from a production SaaS platform we shipped: Aurora PostgreSQL, a worker that can retry, and object storage prefixes that follow the same tenant id. We do not name customers here. The outcome you can test is a new tenant that cannot read another tenant's rows, with defaults present, and a status field that means ready.

Create, isolate, seed, ready

The state machine has those four beats and a failed state. An idempotency key, usually the tenant slug the customer chose, means a retried request does not create a second tenant. The first write is a tenant row with a UUID tenant_id and status provisioning. Everything later keys off that UUID, not off the display name.

Isolation on the relational side is row-level security in a shared database, which is the default we use until a tenant's contract forces a dedicated cluster. Every tenant-owned table carries tenant_id. The application role is not the table owner. PostgreSQL lets owners bypass row-level security unless you force it, so we force it. The session variable is set on every checkout from the pool.

ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
  USING (
    tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
  );

If the setting is missing, the comparison is unknown and the role sees no rows. FORCE matters because a migration role that owns the table would otherwise read every tenant. Migrations run as a separate role with a written break-glass path, not as the application role. Aurora PostgreSQL behavior for privileges is covered in the Aurora PostgreSQL documentation. We rely on that engine, not on a filter in the ORM that the next developer can forget.

Object storage uses the same id. Keys live under tenants/{tenant_id}/. The application role's session policy requires that prefix. A bucket-wide list is not granted to the request path. One bucket and a prefix keeps account limits sane. A bucket per tenant looks like isolation and becomes a quota and cost problem long before it becomes a security win.

SaaS tenant provisioning AWS and the isolation probe

Seeding is explicit. The first admin user, the default roles, empty notification templates, and feature flags at the values your pricing tier promised. We do not copy another tenant's rows as a template. Copying is how tenant A’s draft invoice becomes tenant B’s seed data. Seeds come from versioned fixtures in the deploy artifact.

The probe runs as the application role with app.tenant_id set to the new tenant. It writes a canary row, reads it back, then opens a second connection as a different tenant and asserts the canary is absent. It also asserts the new tenant cannot see a fixture row that belongs to a long-lived canary tenant. Only then does status become ready. If the probe fails, status becomes failed and the step name is stored. Retry is safe because the idempotency key returns the same tenant_id.

Connection pools are the production bug. A pooled connection that keeps app.tenant_id from the previous request leaks rows even when the policy is correct. We set the variable on checkout and reset it on checkin. Transaction-mode poolers need the set on every transaction, not once per connection. The probe uses two connections from the same pooler configuration production uses. A probe that opens a fresh session and bypasses the pooler is a different system than the one customers hit.

Cognito or a custom auth layer attaches the human to the tenant. We use one user pool and a custom:tenant_id attribute rather than a pool per tenant. A pool per tenant hits account quotas and splits password policy into a fleet of snowflakes. The token's tenant claim is what the API sets on the database session. The claim is not a hint the client may override. The API ignores a body field named tenant_id for authorization. That mistake is how a probe passes and a crafted request fails the product.

Defaults that touch other AWS resources, such as a queue or a KMS data key, are steps in the same machine with the same idempotency key. We do not create a customer managed key per tenant by default. Key quotas and cost dominate, and a single key with an encryption context of tenant_id separates cryptographic binding without a key-per-customer estate. A tenant that contracts for a dedicated key is a branch in the state machine, not the default path.

Failure, retry, and what ready means

ready means the probe passed and seeds exist. It does not mean a human clicked around the UI. We keep a synthetic check that logs in as the first admin and calls one read API. That check is the bridge between infrastructure status and product status. If it fails, the tenant stays out of the "you can invite your team" message.

Partial failure is visible. A tenant stuck in provisioning with a step name is an operator queue, not a silent retry loop that creates duplicate prefixes. Alarms fire on age in provisioning, not only on exceptions. A worker that crashes after writing the tenant row and before the probe should resume, not restart from a second UUID.

Full-stack delivery is the application side: the API that refuses a mismatched tenant claim. AWS cloud infrastructure is the database, the bucket policy, and the state machine. Provisioning sits on both. A perfect RLS policy with an API that trusts the request body is not isolation. We test both in the probe and the synthetic login.

Offboarding a tenant is the same machine in reverse: status deprovisioning, access tokens rejected, prefixes retained or deleted according to the contract, and a final state that cannot be flipped back to ready without a new id. We do not share the delete path with the create path's retry logic. A retry of a signup must not delete. The idempotency key on create is not the idempotency key on destroy.

We have run this pattern on a production SaaS platform with Aurora Serverless v2 and a small set of workers, not a dedicated cluster per customer. The tradeoff is honest. Shared schema with forced row-level security is simpler to operate and demands discipline on every new table. A table added without tenant_id and without a policy is a cross-tenant bug. CI checks that every table in the tenant schema has the policy. That check is part of what we deliver, because the state machine cannot see a table someone adds next month.

Frequently Asked Questions

What has to succeed before a tenant is marked ready?

The tenant row exists, data isolation is in place, defaults are seeded from fixtures, and a probe as the application role can see only that tenant's canary. A second connection as another tenant must not see it. Status stays provisioning or moves to failed until that probe passes.

Why force row-level security?

Table owners bypass row-level security unless it is forced. The application role is not the owner, and FORCE ROW LEVEL SECURITY covers the case where a migration role would otherwise read every tenant. A missing app.tenant_id matches no rows. The filter also has to live in the session, not only in application code.

Do you create a database or a KMS key per tenant?

Not by default. A shared Aurora database with tenant_id and a shared bucket prefix scales further than a cluster and a key per customer. Dedicated keys or clusters are a branch for contracts that require them. The default path uses an encryption context of tenant_id on one key.

How do retries avoid duplicate tenants?

The signup idempotency key maps to one tenant_id. A retry resumes that id. Create and destroy use different keys so a replayed signup cannot delete a tenant. Workers that crash mid-way leave a visible provisioning step instead of inserting a second row.

Where does the user pool sit?

One user pool holds every tenant, and the API trusts the tenant id on the token rather than a tenant id in the request body. A pool per tenant hits account quotas and splits password policy. Provisioning consumes that claim and does not create a second login for each tenant. The longer comparison is Cognito versus custom auth.

Talk to us about a tenant provisioning path on AWS. Contact Rutagon at rutagon.com/contact, 907-841-8407, or contact@rutagon.com.