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.

Last reviewed September 17, 2026Fresh

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=PASSWORD and /policies?type=OKTA_SIGN_ON, plus /policies/{id}/rules for the first active sign-on policy
  • /org/factors for the org-level factor list
  • /groups?q=SUPER_ADMIN and /groups/{id}/users for the admin count
  • /users filtered by status eq "DEPROVISIONED" and status eq "SUSPENDED"
  • /threats/configuration, /behaviors and /zones for threat and network settings
  • /api-tokens, /authorizationServers, /apps and /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.

FieldGoes toStored as
Okta domainokta_domain on shield_tenantsClear text. The panel strips https:// and a trailing slash.
API tokenokta_api_token_encrypted on shield_tenantsAES-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:

StatusBody
400okta_domain required
400Invalid okta_domain format. Provide hostname only (e.g., 'yourorg.okta.com').
400Both okta_domain and okta_api_token are required the first time a provider is configured.
500Failed 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.

CategoryChecksWhat they read
mfa5Active MFA enrollment policies, WebAuthn or FIDO2 as an org factor, and whether the SMS, email and recovery factors are active.
lifecycle5Deprovisioned 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.
authentication3Active sign-on policies, active network zones, and the count of active applications.
protocol3Authorization servers, refresh token rotation and a fixed informational note that Okta applies org-level rate limits.
admin2Members of the built-in SUPER_ADMIN group, and API tokens unused for 90 days.
password2The weakest active password policy's minimum length, and its lockout threshold.
session2Session lifetime and idle timeout, both read from the sign-on policy rules.
threat_protection2The ThreatInsight action, and active behavior detection rules.
observability1Whether 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.

ScoreLabel in the tab
80-100Strong
60-79Moderate
40-59Needs work
0-39Critical

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>" }
StatusBodyCause
401Missing authorization header or UnauthorizedNo session JWT, or it has expired.
400Invalid JSON bodyThe body did not parse as JSON.
400tenant_id requiredtenant_id was missing from the body.
403Forbidden: tenant admin access requiredThe signed-in user is not an admin member of that tenant.
500Failed to verify tenant accessThe membership lookup itself failed.
400Unknown actionaction was not a save action, run_scan or retry_failed_checks.
400Okta configuration not found. Configure your Okta domain and API token first.No credentials are stored for this tenant.
400retry_of_scan_id requiredRetry was called without a parent scan id.
404parent scan not foundNo scan row has that id.
403parent scan does not belong to this tenant/providerThe parent scan belongs to another tenant or another provider.
400no failed or warning checks to retryEvery check in the parent scan passed or was skipped.
500Scan 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