A multi-tenant API needs to answer two separate questions: who is making the request, and what may that identity do inside the selected tenant?
Spring Boot SaaS Starter is an open-source backend MVP I maintain to make those boundaries easier to inspect. It provides a Projects API with tenant membership, role checks, optimistic concurrency, and audit records.
The current stack uses Java 25, Spring Boot 4.1.2, PostgreSQL, JDBC, and Flyway. Docker Compose includes Keycloak for local development.
Authentication establishes identity
The API accepts bearer tokens through Spring Security’s JWT resource server support. Its configuration validates the signature and issuer, requires an audience match, and checks for a nonblank subject and an expiration claim.
The subject identifies the caller. Memberships associate that subject with a tenant and a role in the application database.
This distinction matters: a valid token alone does not establish membership in a particular tenant. The tenant UUID in a request selects the tenant to access; authorization still happens in the service layer.
See the implementation in SecurityConfig.java.
Follow the tenant boundary into the database
The starter uses a shared schema with complementary application and database checks:
- A transactional service checks and locks the caller’s membership row.
- It verifies the role required for the operation.
- It sets transaction-local database context for the tenant.
- Queries include explicit tenant predicates.
- PostgreSQL enforces row-level security on projects and audit events.
TenantAccess.java activates the context using a parameterized query:
SELECT set_config('app.tenant_id', ?, true)
The final argument makes the setting local to the transaction. The design therefore avoids leaving tenant state attached to a pooled connection after the transaction finishes.
The database migration enables and forces row-level security on both protected tables. Policies compare each row’s tenant_id with the current transaction’s tenant setting, including checks on writes.
Tenant and membership tables have a different role: they support onboarding and membership discovery across tenants. Their authorization boundary is enforced in services.
The application also uses restricted runtime database credentials, separate from schema migration credentials. Startup checks reject unsafe database privileges or missing RLS enforcement.
These layers help defend against accidentally omitted tenant predicates. They do not make a compromised application or arbitrary SQL execution harmless. The architecture guide explains that boundary explicitly.
Keep permissions specific to each tenant
The role model is deliberately small:
| Capability | OWNER | ADMIN | MEMBER |
|---|---|---|---|
| Read projects and list members | Yes | Yes | Yes |
| Create, update, or delete projects | Yes | Yes | No |
| Read audit events | Yes | Yes | No |
| Manage non-owner memberships | Yes | No | No |
An authenticated identity can create a tenant and becomes its owner. Ownership transfer and owner removal are outside this MVP.
A caller who is not a tenant member receives 404 for tenant resources. A member who lacks the required role receives 403.
Make conflicting writes explicit
Projects carry a version number. Updates match the tenant, project ID, and expected version, then increment the version. Deletions also require the expected version.
For example, after updating version 0 successfully, a deletion that still supplies version 0 receives 409 Conflict.
The client should reload the project and reconcile changes before retrying. Automatically substituting the latest version would defeat the purpose of detecting a stale write.
Project mutations and their audit records occur within the same transaction. See ProjectService.java and the API walkthrough.
Run the local example
The README’s container-based path requires Git, a POSIX shell, Docker Engine with Compose v2, OpenSSL, and Python 3.9+. On Windows, use WSL with Docker access. Ports 8080, 8081, and 5432 must be available.
A host JDK is unnecessary for this path:
git clone https://github.com/chafian/spring-boot-saas-starter.git
cd spring-boot-saas-starter
./scripts/init-env.sh
docker compose up --build -d
python3 scripts/smoke.py
The setup script generates local credentials. The smoke script waits for readiness, signs in as the demo identities, and exercises the API.
It checks that an outsider cannot access the tenant’s projects, that adding the identity as a MEMBER enables reading, and that the same member still cannot create projects. It also exercises updates, stale-write rejection, deletion, audit events, and requests without authentication.
Successful completion produces:
PASS: real OIDC login, tenant isolation, membership, RBAC, CRUD, conflicts, and audit
Windows/WSL troubleshooting: On my machine, Docker could not bind host port 5432. I changed the PostgreSQL port mapping in compose.yml from 127.0.0.1:5432:5432 to 127.0.0.1:5433:5432, then reran docker compose up -d. The application’s internal database connection remained unchanged, and the smoke test passed.
Each run creates a fictional tenant and retains its audit history. Use docker compose down to stop the services while preserving the database volume.
Keycloak runs in development mode and is ephemeral. Follow the README’s reset guidance if it is recreated, because identity subjects may change.
Test and extend the example
For development, install JDK 25 and unzip, keep Docker running, and use:
./mvnw verify
python3 -m unittest discover -s scripts -p 'test_*.py'
The documented Java verification path uses a real PostgreSQL Testcontainer. CI is configured to run verification and the Compose smoke example.
To add another tenant-owned resource, follow the extension guide and preserve the membership, transaction, tenant-context, and database-policy boundaries.
The current scope excludes billing, a frontend, email invitations, tenant deletion, and ownership transfer. Production deployment also requires environment-specific work such as TLS, production identity configuration, tenant-creation quotas or rate limits, backups, monitoring, and secret management. The local Keycloak setup is a development convenience.
If you work with Java SaaS backends, I’d welcome feedback on the isolation model and the setup experience.
Top comments (0)