A whole Auth0 tenant in Pulumi: importing the live system, then making it portable
Codifying every application, role, and OAuth scope of a production Auth0 tenant — without recreating it from scratch — and making it deploy identically across three environments.
A few years ago I worked on putting a live Auth0 tenant under Pulumi management — a few dozen applications, several connections, roles, scopes, branding, and email templates that had accreted in the dashboard over time. This article is the write-up of how we imported that tenant into Pulumi without breaking it, and how we made a single Python codebase produce three identical environments — dev, int, prod — with only configuration differing between them.
The work was done with the older pulumi import flow that generated code per resource. Newer Pulumi versions default to state-only imports with code generation as an opt-in flag (--generate-code); some of what’s described below is shaped by the constraints of the older flow. If you’re starting today, the import workflow looks slightly different, but the architectural decisions about how to structure the resulting code are the same.
Why we didn’t start from scratch
The tempting move when bringing infrastructure under code is to create a fresh tenant from a clean Pulumi program — rewrite it the way it should be, on the side, then cut over. This was almost always wrong for our case. The live tenant had callback URLs registered in third-party services, JWT issuers baked into mobile app builds, and M2M tokens issued to systems across the organisation whose consumers we no longer fully tracked. Cutting over would have meant coordinating a release with every consumer, simultaneously, with no rollback.
The harder but right move was to import the live tenant in place. The pulumi import flow at the time handled this by pointing Pulumi at each existing object’s ID; it would generate the corresponding code, register the resource in Pulumi state, and leave the live tenant unchanged. From there we could refactor incrementally without touching the running system.
Decision: import in waves, document the gotchas inline
The import wasn’t a single command. The tenant had a few hundred resources of different types, and the auto-generated code per resource (the older flow’s default behaviour) was verbose and slightly wrong. The pattern that worked was importing a category at a time — clients, then connections, then roles, then actions, then branding — refactoring each batch into a clean shape, committing, and moving to the next category.
A handful of edge cases were worth a comment in the code rather than a clever solution:
- Connections came back with
custom_scriptspopulated even when no scripts existed. The Auth0 Management API returned a placeholder; Pulumi saw a diff on every plan. We stripped them at import time and addedignore_changes=["custom_scripts"]. - The default branding theme required a UUID even though it was a singleton. We fetched it once via the API explorer, hardcoded the ID in a comment, and imported using that.
- Client grants don’t have descriptive names; their IDs are opaque (
cgr_...). A small script listed them via the Management API, joined to the client they belonged to by ID, and emitted the import commands.
Each was a small Auth0-API quirk; written down once in the code that handled it, none of them needed to be rediscovered later.
Decision: a single codebase, three environments, three tenants
Worth being explicit because the alternative was the insecure mistake people make: each environment was its own Auth0 tenant (separate *.eu.auth0.com domains for dev, int, and prod), not three sets of differently-named clients living inside one shared tenant. The shared-tenant approach would have been a security regression — a misconfigured dev callback can become a prod attack vector — and it’s also operationally awkward, because Auth0’s tenant-level settings (custom domain, branding, MFA policy) can’t be diverged per “environment-by-naming-convention” inside a single tenant.
The whole codebase was a single __main__.py of about a thousand lines, plus three stack files (Pulumi.dev.yaml, Pulumi.int.yaml, Pulumi.prod.yaml) that injected environment-specific values, including the Auth0 domain. Stack identification at runtime drove every conditional:
import pulumi
import pulumi_auth0 as auth0
ENV = pulumi.get_stack() # "dev" | "int" | "prod"
config = pulumi.Config()
LOGISTICS_PREFIX = config.require("logistics_prefix") # e.g. "partner-dev"
APP_PREFIX = config.require("app_prefix") # e.g. "app-dev"
API_AUDIENCE = config.require("api_audience") # e.g. "https://api-dev.example.com"
The same client definition produced different live applications in each environment because the prefix substituted into the callback URLs:
client = auth0.Client(
"logistics-manager",
name="Logistics Manager",
callbacks=[f"https://{LOGISTICS_PREFIX}.example.com"],
allowed_origins=[f"https://{LOGISTICS_PREFIX}.example.com"],
grant_types=["authorization_code", "refresh_token"],
app_type="spa",
token_endpoint_auth_method="none",
)
The win was uniformity: a new security setting (token rotation, refresh-token reuse detection, stricter MFA enrolment) applied to all three environments by changing one default. The old failure mode — the dev tenant has the new setting but prod doesn’t because somebody forgot to clone the change — became structurally impossible.
Decision: data-driven definitions, not copy-paste
A dozen OAuth applications shared similar shapes — different names, different callbacks, similar grant types and scopes. Defining them by hand was about a hundred lines of near-duplicate Python. The version that survived long-term was data-driven:
from typing import TypedDict, NotRequired
class ClientSpec(TypedDict):
name: str
callbacks: list[str]
grant_types: list[str]
scopes: NotRequired[list[str]]
clients: list[ClientSpec] = [
{
"name": "logistics-manager",
"callbacks": [f"https://{LOGISTICS_PREFIX}.example.com"],
"grant_types": ["authorization_code", "refresh_token"],
},
{
"name": "app",
"callbacks": [f"https://{APP_PREFIX}.example.com"],
"grant_types": ["authorization_code", "refresh_token"],
"scopes": ["read:profile", "write:profile"],
},
# ... etc
]
for spec in clients:
client = auth0.Client(spec["name"], ...)
auth0.ClientGrant(
f"{spec['name']}-grant",
client_id=client.client_id,
audience=API_AUDIENCE,
scopes=spec.get("scopes", []),
)
Adding a new client became a 4-line dict; removing one was a 4-line removal. The shape of the data was enforced by the TypedDict, which caught the “I forgot to set callbacks for the new SPA client” mistake at type-check time instead of at deploy time.
Decision: client secrets are managed by Pulumi but not imported
The Auth0 provider models the client and its credentials as two separate resources: auth0.Client for the OAuth client itself, and auth0.ClientCredentials for the secret. The secret is created by Auth0 at resource-creation time, surfaces as a Pulumi output, and is pinned with ignore_changes=["client_secret"] so external rotation in the Auth0 dashboard doesn’t trigger a Pulumi diff:
client = auth0.Client("machine-to-machine-api", ...)
client_credentials = auth0.ClientCredentials(
"machine-to-machine-api",
client_id=client.client_id,
authentication_method="client_secret_post",
opts=pulumi.ResourceOptions(ignore_changes=["client_secret"]),
)
pulumi.export(
"machine_to_machine_api_client_secret",
client_credentials.client_secret,
)
The secret never lives in the Pulumi program (no hardcoded value, no config.require_secret). It does live in Pulumi state as an encrypted output, which downstream Pulumi stacks read via stack references rather than via AWS Secrets Manager or a separate rotation system. Pulumi’s job here is to know the shape of the OAuth setup and to be the system of record for the credential — separate from being the system that delivers it to runtime consumers, which is whatever each consumer’s deployment plumbing already does (env vars in ECS task definitions, mounted secrets in Kubernetes, etc., each pulling from the Pulumi stack export at deploy time).
If the credential later needed to be rotatable on a schedule independent of pulumi up, the pattern would be to drop the ignore_changes and either let Pulumi rotate it on demand (forcing a re-deploy) or move the credential into a real secrets-rotation system (Secrets Manager + a rotation Lambda, Vault dynamic credentials). Neither was in place — the credentials changed rarely enough that quarterly manual rotation through pulumi up was operationally fine.
What was configured (and what wasn’t) on the security side
For a post about codifying an Auth0 tenant, the security-relevant settings are worth being explicit about — both what was in code and what wasn’t. In code:
- All clients were
oidc_conformant=True, with SPAs usingapp_type="spa"+token_endpoint_auth_method="none", which together force PKCE on the authorization-code flow. - Refresh-token rotation was the default for every interactive client (
rotation_type="rotating", 30-daytoken_lifetime), with the M2M Management API client deliberately non-rotating because it backed automation that couldn’t easily handle rotation. - Tenant-level session lifetimes were set (168h session, 72h idle) rather than left at Auth0’s defaults.
- Custom domain, branding, email templates, and the
post-loginaction were all in code, including their environment-specific differences.
Not in code:
- OIDC backchannel logout URLs per client. None were configured; logout relied on access-token expiration plus the standard
oidc_logoutredirect flow. Worth wiring up if any of the apps had real session-hijack risk; for our case the app set was internal-facing enough that the risk was low. - MFA enrolment policy. Auth0’s MFA policy is configurable in code via the
Guardianresources, but at the time we left this in the dashboard. A future iteration would have moved it into Pulumi alongside the rest of the tenant.
What it actually bought
Three things, in roughly the order I appreciated them:
- Diff-driven changes. Every change to the tenant became a PR with a Pulumi preview. Code review caught “you accidentally widened the callback wildcard”. The dashboard turned into a place to verify changes, not to make them.
- Environment parity. The bug class “feature works in dev but not in prod because the role permission graph is subtly different” went away. New configuration landed in all three environments at once, or in none.
- Documentation by virtue of being code. When somebody asked “what scopes does the logistics-manager M2M client have?”, the answer was a
git grep, not a screen-share session in the dashboard.
What I’d warn the next person about
- Auth0 provider lag. New Auth0 features sometimes ship in the dashboard before the Pulumi provider supports them. Plan for a few weeks of “this lives in Pulumi, except for X which we’ll add when the provider catches up”, and comment those exceptions inline.
- Drift from imported state. If anyone clicks in the dashboard during the migration window, your imported Pulumi state diverges from reality. Plan a strict freeze (“nobody touches the dashboard until import is complete”) and announce it loudly.
Why this approach works for stateful SaaS dashboards in general
Auth0 belongs to the broader category of systems with a stateful web UI that rewards careful manual configuration and punishes any failure to remember exactly what you did — Cloudflare, Stripe, the DNS providers, increasingly the per-team SaaS dashboards a modern company runs on. The pattern that produces the right outcome on all of them is the same: import the live state, then make it portable, then refactor under preview-driven change control. The cost is roughly one engineer-week per tenant. The return is that the configuration becomes both legible and trustworthy — legible because it’s in source control, trustworthy because every change goes through a Pulumi preview a reviewer reads before it touches production.