I've been playing recently with Okta and obviously I'm going to use PowerShell: access reports, export scripts, lifecycle jobs, the usual. What I did not have was anywhere honest to run them.
A fresh Okta trial org is empty. One user, me. No meaningful groups, no app integrations, no policies, no trusted origins, no event hooks. Every script I pointed at it passed, because there was nothing in there to get wrong. Then I ran the same code against a real tenant and it fell over on a name with an accent in it, on a user assigned to an app directly rather than through a group, and on a group that was empty for a perfectly legitimate reason.
The obvious fix is to generate test data. The obstacle is the licence. An Okta trial org allows ten active users, and my own admin account is one of them. The usual approach of generating five hundred users and calling that coverage is not a strategy here, it is a licence error on the eleventh row.
So I built OktaTestEnvironment, a PowerShell module that seeds a deliberately awkward Okta lab inside that ten user ceiling and tears it down again cleanly. It lives here: OktaTestEnvironment.
The constraint is the design
Eight users is not a sample size. It is what ten minus my admin accounts leaves room for. Since I could not buy complexity with volume, each of the eight had to earn its place by being awkward in a way that actually breaks scripts, and everything else had to come from the parts of Okta that are not licence capped, which is nearly all of them.
| Object | Count | Why it is there |
|---|---|---|
| Users | 8 | The only licence capped object |
| Groups | 17 | Overlapping, empty and rule driven membership shapes |
| Group rules | 3 | Dynamic membership from three different attribute kinds |
| App integrations | 8 | Turns "who exists" into "who has access to what" |
| Custom attributes | 10 | Five data types, across two user schemas |
| User types | 2 | A second schema most scripts never look at |
| Network zones | 2 | Allow and blocklist, for policies to condition on |
| Policies | 3 | Overlapping sign-on precedence, plus a password policy |
| Trusted origins | 2 | Differing scopes, and a fresh org has none |
| Event hooks | 2 | Outbound webhooks, and a fresh org has none |
| Linked objects | 1 pair | Okta's real relationship primitive |
The users are the fun part. José, Zoë and Tomás carry non-ASCII names with ASCII logins, which is what a real directory looks like and which Windows PowerShell will happily replace with question marks on export unless you tell it not to. Zoë carries a risk score of zero, because zero is falsy in PowerShell and if ($value) { ... } drops it silently. Hana has no entitlements at all, which is the empty array case. Marcus is suspended, Owen is staged and has never signed in. Both still consume a licence slot, which is the reason eight is a hard number rather than a soft target.
What you would actually point at it
- Access review and reporting scripts. Marcus sits in Sales, the Engineering Wiki is assigned to Engineering, and he is assigned to it directly. A group only access report misses him entirely. That is the most common access review bug there is and you cannot reproduce it without apps.
- Export and flattening logic.
labEntitlementsis a multi-valued array,labRiskScoreis an integer that is sometimes zero,labContractEndDateis an ISO 8601 string. A schema of nothing but strings will never tell you that your CSV writesSystem.Object[]. - Schema aware tooling. There are two user types, and each has its own independent schema. Two attributes exist only on the
Contractortype, so anything reading/api/v1/meta/schemas/user/defaultgenuinely cannot see them. - Policy and precedence reporting. Two sign-on policies deliberately overlap. Okta assigns priority by creation order, newest first, so a report that lists policies without their order tells you nothing about what actually applies.
- Relationship handling. The seeded users carry both a
managerprofile string and alabMentor/labMenteelinked object pair, and the mentoring links deliberately do not mirror the management chain. The string is just text, nothing validates it or stops it naming somebody who left two years ago. A linked object is a real reference Okta maintains on both sides. Conflate them and you get a visibly different answer. - Auth pattern reference. The service app path is a working
private_key_jwtclient credentials implementation with no SDK involved, which is useful on its own.
Getting it running
Import-Module .\OktaTestEnvironment.psd1
$token = Read-Host 'SSWS token' -AsSecureString
Connect-OktaTestEnvironment -OrgUrl https://trial-123456.okta.com -ApiToken $token
New-OktaTestEnvironment -WhatIf # see it before you do it
New-OktaTestEnvironment
The default path installs nothing. RequiredModules is empty and a contract test enforces it:
# Required Modules
# Deliberately none. Everything is done with Invoke-WebRequest and the .NET crypto types,
# so this runs on a stock host with no gallery installs and nothing to keep in step.
RequiredModules = @()
Every call goes through Invoke-WebRequest, the JWT is signed with the in-box .NET crypto types, and on Windows the private key is encrypted with DPAPI. A lab module that first requires you to install an SDK is one more thing to get working before you can start.
There is exactly one exception and it is opt-in. -UseSecretStore puts the key in an encrypted vault rather than in the credential file, and that genuinely needs Microsoft.PowerShell.SecretManagement and Microsoft.PowerShell.SecretStore from the Gallery. They are installed on demand under that flag instead of being declared upfront, so nobody pays for a vault they never asked for.
It is the option to reach for off Windows, because DPAPI is not there to fall back on. ConvertFrom-SecureString does not throw on Linux or macOS and does not encrypt either, it hands back the UTF-16 bytes of the plaintext as hex. Verified on PowerShell 7.4 on Debian, where the stored key came back as 5300450043... and decoded straight back to the original. What the module does now is refuse to take the platform check's word for it: after encrypting, it tries to read the result back as hex and fails the whole operation if the plaintext is still in there. Anything that falls through returns Protection: None and warns loudly, rather than recording Encrypted: True over a credential anyone can read.
Twelve steps, and skipping the ones you do not want
New-OktaTestEnvironment is an orchestrator over twelve steps, run in the only order that works, because each depends on the last. A user type has to exist before its schema can be extended or a user assigned to it. Groups have to exist before rules can target them. Zones have to exist before policy rules can condition on them. The service app goes last, because it is the handover.
The steps are declared as data rather than as twelve repetitions of the same try/catch:
# Each step is the same shape: attempt, record, keep going. Declaring them as data
# rather than repeating the try/catch five times keeps the ordering visible, which is
# the part of this function that actually matters.
$steps = @(
@{
Key = 'GroupRules'
Title = 'Step 4: Creating group rules'
Run = {
New-OktaTestGroupRule -PassThru -Confirm:$false
}
Report = { param($r) "$($r.CreatedRules) created, $($r.ActivatedRules) activated" }
}
Every step reports rather than throws, so one failure does not cost you the other eleven, and the summary at the end tells you what actually happened. -Skip and -Keep take the same twelve names, which makes rebuilding one layer cheap:
# Directory objects only, no policies, hooks or origins
New-OktaTestEnvironment -Skip NetworkZones, Policies, TrustedOrigins, EventHooks
# Rebuild just the access management layer over an existing directory
New-OktaTestEnvironment -Skip UserTypes, Schema, Users, Groups, GroupRules
There is deliberately no ShouldProcess gate at the orchestrator level. Each step function implements its own and $WhatIfPreference propagates into them, which is what makes -WhatIf list the eight users and seventeen groups by name. Gating at the top instead produced a preview that said only "would perform step 2", which tells you nothing you did not already know from reading the parameter.
The handover from token to app
You paste an SSWS token once. The module uses it to register an OAuth service app, generates an RSA key pair locally and sends only the public half, then authenticates as that app from then on:
Connect-OktaTestEnvironment -OrgUrl https://trial-123456.okta.com -ServiceApp
Each request signs a short lived assertion rather than transmitting a long lived secret:
$header = [ordered]@{ alg = 'RS256'; typ = 'JWT' }
if ($PrivateJwk.kid) { $header.kid = $PrivateJwk.kid }
$issuedAt = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$payload = [ordered]@{
iss = $ClientId
sub = $ClientId
aud = $Audience
iat = $issuedAt
exp = $issuedAt + $LifetimeSeconds
jti = [Guid]::NewGuid().ToString()
}
Once that works, revoke the SSWS token. It has done its only job. The module will retire it for you, but only if you name it exactly, because GET /api/v1/api-tokens returns ids and names and never the token value, so a string in hand cannot be matched to a row. Guessing would eventually revoke whatever Terraform or CI is using.
Refusing before it half works
The licence check runs before anything is created, so you get a clear refusal up front rather than a partly seeded tenant and an error somewhere in the middle of a loop. The interesting part is what it does not count:
$inUse = $existing.Count
$available = [Math]::Max(0, $ActiveUserLimit - $inUse)
return [PSCustomObject]@{
Limit = $ActiveUserLimit
InUse = $inUse
Available = $available
SeededInUse = $seeded.Count
AvailableForSeed = $available + $seeded.Count
ExistingLogins = @($existing | ForEach-Object { $_.profile.login })
}
Available and AvailableForSeed differ because a user the module already created needs no new slot, seeding updates it in place. Counting those against the ceiling meant the module refused to re-run against the environment it had just built, which is exactly the situation you are in when a seed run fails halfway and you want to fix the cause and try again.
Reading it back
Get-OktaTestEnvironmentReport covers every object type the module creates and emits Console, JSON, HTML or CSV. The detail that matters is that it reads every user type's schema rather than only the default, because reading only the default is the exact mistake the second user type exists to expose. A report built against this lab that quietly omits two contractor attributes has just demonstrated the bug you would otherwise have shipped.
It is also a useful way to meet Okta's rate limits on purpose. The report asks for each app's group and user assignments separately, so a full run is around forty calls, and on a trial org's low per-minute ceiling that can trip a 429. The retry handles it and the report simply takes longer, which is worth seeing once before your own reporting script meets the same wall in front of someone else.
Teardown that proves ownership
A deleted Okta user cannot be restored. There is no recycle bin. So nothing is deleted for merely looking like test data:
$seeded = $all | Where-Object {
$taggedProfile = $_.profile
$byTag = $taggedProfile.PSObject.Properties['labSeedTag'] -and
$taggedProfile.labSeedTag -eq $Prefix
$byDomain = $taggedProfile.login -and
$taggedProfile.login.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase)
$byTag -or $byDomain
}
Two independent markers, either sufficient. The tag is authoritative. The email domain is the fallback for an interrupted teardown that removed the schema attribute before the users, which would otherwise strand them beyond the reach of the tool that made them. Groups require both a name prefix and a description marker. Apps require the label prefix plus a URL under the seed domain, because the tenant I built this against already held apps called Google Workspace and Postman api, and a prefix match alone is how one of those gets deleted.
Deletion order is forced by Okta's own dependencies and runs in reverse of creation. Hooks, origins, policies and zones first, because Okta refuses to delete a zone a policy rule still points at. User types genuinely last, because a type cannot be deleted while a user is on it. -WhatIf beats -Force, so if both are passed nothing is deleted.
Gotchas that cost me real time
- A single
DELETEon an active user only deactivates it. Okta deletes in two steps. Teardown that skips the deactivate reports success and leaves the user holding a licence slot. - Scopes and roles are different things. Scopes say which APIs the app may call, the admin role says which objects it may touch. An app with scopes and no role authenticates fine and is authorised for nothing.
- Use the org authorisation server. Tokens carrying
okta.*scopes only come from/oauth2/v1/token. A token from/oauth2/default/v1/tokenis issued happily and then rejected by every management call. - Okta discards the app profile on everything except OIDC. The
POSTsucceeds and the response quietly omits it. Keying teardown on that marker alone found one app out of eight and abandoned the other seven. - Group rules reach staged and suspended users. The opposite of what most people assume, which means a rule granting an entitlement reaches accounts nobody has ever signed into.
- "The request body was not well-formed" can mean "wait a bit". Deleting schema bearing objects is asynchronous and the name stays reserved for some seconds afterwards. A user type gives you a bare
E0000003naming nothing, and the identical body succeeds a minute later. The module retries on those signatures alone. - You cannot create custom SAML apps through the API.
saml_2_0,template_saml_2_0,saml_2_0_customandcustom_saml_2_0all return 404. Build one in the admin console if you need it. - Event hook URLs have to resolve. Okta validates the hostname and rejects one that does not, so a tidy looking
hooks.oktalab.example.comfails with "Invalid URL provided" whileexample.comis accepted. That is why the hooks point at the latter rather than at the lab domain everything else uses. - Users are always created with a password. Create an Okta user without credentials and Okta sends a real activation email to whatever is on the profile. The seeded addresses sit under
example.com, which RFC 2606 reserves so test data cannot reach anybody, but the tenant would still record eight bounced activations. SUPER_ADMINis the default role for the service app. Managing the user schema needs it and the target is disposable. On anything you care about, pass-AdminRole USER_ADMIN, APP_ADMINand accept that-Skip Schemabecomes mandatory.
Tests
Pester 6 unit tests live in Tests\Unit\, and every Okta call is mocked, so the suite reaches no tenant and burns none of the ten slots:
Import-Module Pester -MinimumVersion 6.0.0
Invoke-Pester -Path .\Tests
Invoke-Pester -Path .\Tests -TagFilter 'Destructive'
Several of those tests are regressions for bugs the mocks could never have found, including one where adding a step to the orchestrator without mocking it meant the tests made real network calls. There is now a backstop mock that throws on any request escaping the others, so the next step added cannot silently reach a tenant.
If you write anything against Okta and you have been testing it against an empty org, that is the gap this fills. Point it at a disposable tenant, run your reports, and find out which of your assumptions were only ever true because there was nothing there to contradict them.