Skip to Content
TutorialsOnboard a SaaS tenant

Onboard a SaaS tenant

This tutorial provisions one customer security namespace. At the end, the customer has its own issuer, users, OAuth clients, resource audiences, grants and signing keys.

The order matters. Configure a custom issuer immediately after tenant creation. Creating a client, non-UserInfo resource or issuer-dependent credential locks later issuer changes.

Before you start

The operator needs a control-tenant access token with identity.admin.manage and the matching live permission.

From the Identity checkout:

export IDENTITYCTL_ISSUER='https://auth.identity.lookandtry.com' ./identityctl login export IDENTITY_ADMIN_ACCESS_TOKEN="$(./identityctl token)" export CONTROL_ISSUER='https://auth.identity.lookandtry.com'

Use a dedicated operator session and environment. Confirm ./identityctl status before any production mutation.

Decide these customer values first:

ValueExample
tenant slugacme
registration policyCONTROLLED or SELF_SERVICE
final issuermanaged issuer or https://login.acme.example
API resource URIhttps://api.acme.example
token audienceacme-api
web callbackhttps://app.acme.example/api/auth/callback

1. Create the tenant

tenant_json="$( curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $IDENTITY_ADMIN_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ --data '{"slug":"acme","registrationMode":"CONTROLLED"}' \ "$CONTROL_ISSUER/identity/admin/tenants" )" printf '%s\n' "$tenant_json" | jq tenant_id="$(printf '%s' "$tenant_json" | jq -er '.id')" managed_issuer="$(printf '%s' "$tenant_json" | jq -er '.managedIssuer')"

The response is 201 Created and contains both issuer and managedIssuer. With no custom domain, keep the generated issuer and skip the next step.

Record tenant_id as the management identifier. Do not use the editable slug as a database key in automation.

2. Optionally set the custom issuer

Complete DNS, TLS certificate and ingress routing for the custom origin first. The origin must not contain a path. Then update the still-empty tenant:

custom_issuer='https://login.acme.example' tenant_json="$( curl --fail-with-body --silent --show-error \ -X PUT \ -H "Authorization: Bearer $IDENTITY_ADMIN_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ --data "{ \"slug\": \"acme\", \"issuer\": \"$custom_issuer\", \"registrationMode\": \"CONTROLLED\" }" \ "$CONTROL_ISSUER/identity/admin/tenants/$tenant_id" )" issuer="$(printf '%s' "$tenant_json" | jq -er '.issuer')" test "$issuer" = "$custom_issuer"

A 409 tenant_issuer_locked means issuer-dependent state already exists. Do not work around it with database edits. Keep the existing issuer or provision a replacement tenant and migrate deliberately.

3. Verify discovery before adding consumers

discovery="$( curl --fail-with-body --silent --show-error \ "$issuer/.well-known/openid-configuration" )" test "$(printf '%s' "$discovery" | jq -er '.issuer')" = "$issuer" printf '%s\n' "$discovery" | jq '{ issuer, authorization_endpoint, token_endpoint, jwks_uri }'

Stop if TLS, routing or exact issuer equality fails. A client configured before this check will only make the inconsistency harder to unwind.

4. Register the customer API

curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $IDENTITY_ADMIN_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ --data '{ "uri": "https://api.acme.example", "audience": "acme-api", "scopes": ["orders.read", "orders.refund"] }' \ "$CONTROL_ISSUER/identity/admin/tenants/$tenant_id/resources" | jq

The resource URI is requested through OAuth’s resource parameter. The API validates the returned token’s aud against acme-api.

Choose scopes around operations a client can request. Product roles and object-level permissions stay in the product backend.

5. Register the web application

issued_client="$( curl --fail-with-body --silent --show-error \ -H "Authorization: Bearer $IDENTITY_ADMIN_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ --data '{ "clientId": "acme-web", "displayName": "Acme web", "applicationType": "WEB", "tokenEndpointAuthMethod": "CLIENT_SECRET_BASIC", "consentPolicy": "EXPLICIT", "redirectUris": ["https://app.acme.example/api/auth/callback"], "scopes": ["openid", "email", "offline_access", "orders.read"], "defaultResource": "https://api.acme.example", "nativeAuthenticationEnabled": false }' \ "$CONTROL_ISSUER/identity/admin/tenants/$tenant_id/clients" )" printf '%s\n' "$issued_client" | jq

Capture clientSecret immediately into the BFF’s secret manager. It is not returned by later list operations.

For a native application, use applicationType: "NATIVE", tokenEndpointAuthMethod: "NONE" and an exact native callback. Enable nativeAuthenticationEnabled only when the client will use the first-party native credential routes.

Security boundary

A client secret, resource-server credential and operator token are different authorities. Store and rotate each in the service that owns it; never copy them into frontend configuration.

6. Choose the account creation policy

  • CONTROLLED: create or import users through the Management API, invitations or a product-owned onboarding process.
  • SELF_SERVICE: enable registration routes and ensure email delivery works before exposing a sign-up action.

Changing the mode does not create users and does not migrate product memberships.

7. Verify the customer boundary

Run these release checks:

  1. discovery from the final issuer returns that exact issuer;
  2. the client callback is present exactly once with no wildcard;
  3. an authorization request for https://api.acme.example can request orders.read;
  4. the resulting access token has issuer issuer and audience acme-api;
  5. the same token is rejected by another tenant’s API;
  6. the customer API resolves product users by (issuer, sub);
  7. the client secret exists only in the BFF secret store;
  8. Management API audit events identify the operator and created resources.

Use Management API responses, custom issuer operations and the web BFF tutorial for the next steps.