Okta Identity Security Posture Management (ISPM)
Console-only posture scanning for an Okta org: 25 weighted checks in nine categories, run from the dashboard Posture tab and scored 0-100.
Overview
PasskeyBridge reads an Okta org's configuration through the Okta Admin API, runs 25 checks against it, and stores each one as a finding with a weight, a status and evidence. The scan writes one row to shield_posture_scans and one row per check to shield_posture_findings, then the dashboard renders a 0-100 posture score and the findings grouped by category. Nothing in your Okta org is changed: every call the scan makes is a GET.
It lives in the dashboard Posture tab, under the Okta provider tab.
Who can run it. Posture is an Enterprise tab: on the starter and pro plans the dashboard shows an upgrade panel in place of the tab. The plan gate is in the dashboard only. The shield-okta-ispm function enforces something different: the caller must present a valid dashboard session and be an admin member of the tenant in shield_tenant_members.
Console-only. shield-okta-ispm is not in the Cloudflare worker's PUBLIC_FUNCTIONS list, so it has no https://api.passkeybridge.io/v1 route and no API key lane. The dashboard calls it directly with supabase.functions.invoke and your session JWT.
Okta API token and dashboard fields
In Okta. Create an API token in the Okta Admin Console under Security > API > Tokens, signed in as a user whose admin role can read the resources below. The token inherits that user's permissions. The scan sends it as an SSWS authorization header against https://your-org.okta.com/api/v1 and reads:
/policies?type=MFA_ENROLL,/policies?type=PASSWORDand/policies?type=OKTA_SIGN_ON, plus/policies/{id}/rulesfor the first active sign-on policy/org/factorsfor the org-level factor list/groups?q=SUPER_ADMINand/groups/{id}/usersfor the admin count/usersfiltered bystatus eq "DEPROVISIONED"andstatus eq "SUSPENDED"/threats/configuration,/behaviorsand/zonesfor threat and network settings/api-tokens,/authorizationServers,/appsand/logs?limit=1
A call that fails does not fail the scan. The check that needed it is recorded as skipped with the HTTP error in its evidence, and the remediation text on that finding names the permission to add. Okta editions differ: /threats/configuration and /authorizationServers answer 404 on some orgs, and the module says so in the finding instead of marking it failed.
In the dashboard. Posture tab, Okta provider tab, expand Okta configuration.
| Field | Goes to | Stored as |
|---|---|---|
| Okta domain | okta_domain on shield_tenants | Clear text. The panel strips https:// and a trailing slash. |
| API token | okta_api_token_encrypted on shield_tenants | AES-256-GCM under the server-held SHIELD_ENCRYPTION_KEY. |
Press Save. The panel calls the same function with the save action.
{ "action": "save_okta_config", "tenant_id": "<tenant uuid>", "okta_domain": "your-org.okta.com", "okta_api_token": "00abc123" }A successful save answers {"ok": true} and writes a save_okta_config audit row. Failures:
| Status | Body |
|---|---|
| 400 | okta_domain required |
| 400 | Invalid okta_domain format. Provide hostname only (e.g., 'yourorg.okta.com'). |
| 400 | Both okta_domain and okta_api_token are required the first time a provider is configured. |
| 500 | Failed to save provider configuration |
The token field's helper text offers to keep the stored value if you leave it blank, and since 2026-09-17 the function honours that: a save with no token keeps the one already stored and updates the domain alone. A token is required only the first time, when there is nothing to keep, and a blank token never blanks a stored credential. Until then every re-save answered 400, so a configured provider's domain could not be corrected without pasting the token again, which the panel never sends back to the browser.
Checks by category
25 checks run on every scan, in nine categories. The tab groups findings under these category names with the underscores replaced by spaces.
| Category | Checks | What they read |
|---|---|---|
| mfa | 5 | Active MFA enrollment policies, WebAuthn or FIDO2 as an org factor, and whether the SMS, email and recovery factors are active. |
| lifecycle | 5 | Deprovisioned and suspended user counts, plus three checks that read the app inventory or the deprovisioned count and ask you to verify provisioning, group push and deactivation by hand. |
| authentication | 3 | Active sign-on policies, active network zones, and the count of active applications. |
| protocol | 3 | Authorization servers, refresh token rotation and a fixed informational note that Okta applies org-level rate limits. |
| admin | 2 | Members of the built-in SUPER_ADMIN group, and API tokens unused for 90 days. |
| password | 2 | The weakest active password policy's minimum length, and its lockout threshold. |
| session | 2 | Session lifetime and idle timeout, both read from the sign-on policy rules. |
| threat_protection | 2 | The ThreatInsight action, and active behavior detection rules. |
| observability | 1 | Whether the System Log is readable with this token. |
Six checks are inferred rather than queried. Session Lifetime Policy and Idle Session Timeout both read the rules fetched for the sign-on policy check. Refresh Token Rotation reports a warning whenever custom authorization servers exist, because the setting is per server. Group Push Configuration and Automated Provisioning warn whenever the app inventory is non-empty. Automated Deactivation reads the deprovisioned count. Rate Limit Awareness always passes with impact 0.
The admin count is group-based. Okta has no endpoint that lists super admins, so the check looks for a group named SUPER_ADMIN or Super Administrator and counts its members. If no such group is visible to the token the check is skipped rather than reporting zero admins.
Posture score
The score is a penalty ratio over the checks that actually ran. Every check carries a hard-coded weight and a score_impact: zero when it passes, a negative number when it fails or warns. The function computes, over all non-skipped checks, 100 x (1 - total_penalty / max_penalty), where total_penalty is the sum of the absolute score_impact values and max_penalty is the sum of weight x 2. It rounds it and clamps it to 0-100. Weights run from 2 to 8. Okta's heaviest checks are the MFA enrollment policy and phishing-resistant MFA at 8 each.
There are no severity multipliers. severity (critical, high, medium, low, info) is a label on the finding row and has no effect on the arithmetic.
Skipped checks leave both sides of the ratio, so a check that could not run neither helps nor hurts. If every check is skipped the score is 0.
The tab turns the number into one of four labels.
| Score | Label in the tab |
|---|---|
| 80-100 | Strong |
| 60-79 | Moderate |
| 40-59 | Needs work |
| 0-39 | Critical |
Credential and result storage
Credentials. Only the API token is encrypted. It is written with AES-256-GCM under SHIELD_ENCRYPTION_KEY and decrypted inside the function for the length of one scan. The encryption is mandatory: if the key is missing the save fails rather than storing plaintext. The Okta domain is stored in clear because the scan needs it to build URLs. A migration revokes column-level SELECT on okta_api_token_encrypted from the authenticated and anon roles, so the ciphertext never reaches a browser.
Findings. Evidence holds counts and configuration values such as admin_count, min_length or active_policy_count. Individual Okta user records are counted and discarded inside the function.
Scan rows land in shield_posture_scans and one finding row per check in shield_posture_findings. Both tables are tenant-admin read-only under row-level security: insert, update and delete are denied to the authenticated role, and the function writes them with its service-role client.
Audit trail. Every scan writes a shield_audit_log row. The Okta module keeps the older action verb run_posture_scan for existing consumers; the other providers use run_ plus the provider id plus _posture_scan. The row carries the provider, the score, the check counts and the scan id, and a failed scan writes the same row with result set to failure and the error message.
Running a scan and retrying failures
Scans run on demand. No cron job calls any ISPM function, so a scan happens when someone presses a button in the Posture tab. Open the tab, select Okta, and press Run scan in the tab header. The button is disabled while a scan is in flight and the tab reloads the scan list when it finishes.
The button invokes the function with your signed-in session.
const { data, error } = await supabase.functions.invoke("shield-okta-ispm", {
body: { action: "run_scan", tenant_id: "<tenant uuid>" },
});A completed scan answers with the summary the tab renders.
{
"scan_id": "7c1f0b2e-...",
"parent_scan_id": null,
"posture_score": 68,
"checks_total": 25,
"checks_passed": 14,
"checks_failed": 5,
"checks_warning": 4,
"checks_skipped": 2,
"duration_ms": 8421,
"partial_failures": []
}Retry failed checks appears in the Findings header once a completed scan has at least one failed or warning check. It creates a second scan that re-runs only the failed and warning check ids and copies the passed and skipped findings forward, so a skipped check changes only on a full Run scan.
{ "action": "retry_failed_checks", "tenant_id": "<tenant uuid>", "retry_of_scan_id": "<scan uuid>" }| Status | Body | Cause |
|---|---|---|
| 401 | Missing authorization header or Unauthorized | No session JWT, or it has expired. |
| 400 | Invalid JSON body | The body did not parse as JSON. |
| 400 | tenant_id required | tenant_id was missing from the body. |
| 403 | Forbidden: tenant admin access required | The signed-in user is not an admin member of that tenant. |
| 500 | Failed to verify tenant access | The membership lookup itself failed. |
| 400 | Unknown action | action was not a save action, run_scan or retry_failed_checks. |
| 400 | Okta configuration not found. Configure your Okta domain and API token first. | No credentials are stored for this tenant. |
| 400 | retry_of_scan_id required | Retry was called without a parent scan id. |
| 404 | parent scan not found | No scan row has that id. |
| 403 | parent scan does not belong to this tenant/provider | The parent scan belongs to another tenant or another provider. |
| 400 | no failed or warning checks to retry | Every check in the parent scan passed or was skipped. |
| 500 | Scan failed: ... | The scan threw. The scan row is stored with status failed and the thrown message. |
Confirm it worked. The score card shows the band, the score out of 100, and the Passed, Failed and Warnings tiles. The card under it shows Scan, Duration, Started and Type, where Type reads Full scan or Retry, N checks. Findings are grouped by category below; expanding one shows its description, Remediation, Evidence JSON, and a line reading Weight, impact and check with that check's numbers and id. The Alerts section in the same tab can post a completed scan to a Slack webhook or to email recipients once its failed-check threshold is met.
Related from the blog
- Roaming Steering and Identity: When the HPLMN You Trust Hands You to a VPLMN You Don'tsecurity · 20 min read
- Network Slicing in 5G Standalone and the Birth of Per-Slice Identity Posturentn · 15 min read
- Agent Attestation Fatigue: When Too Many Trust Checks Become the Vulnerabilitysecurity · 21 min read