Microsoft Entra ID Identity Security Posture Management (ISPM)

Console-only posture scanning for a Microsoft Entra ID tenant: 25 weighted Microsoft Graph checks in ten categories, run from the dashboard Posture tab and scored 0-100.

Last reviewed September 16, 2026Fresh

Overview

PasskeyBridge reads a Microsoft Entra ID tenant through Microsoft Graph, runs 25 checks against its Conditional Access, authentication method, directory role and logging configuration, and stores each one as a finding with a weight, a status and evidence. The dashboard renders a 0-100 posture score and the findings grouped by category. Every Graph call the scan makes is a read.

It lives in the dashboard Posture tab, under the Microsoft Entra ID provider tab. Scan rows carry provider set to entra_id.

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-entra-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-entra-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.

App registration and dashboard fields

In Entra ID. Register an application, give it the application permissions listed in the next section, and grant admin consent. Create a client secret for it.

The function exchanges those credentials for a Graph token at https://login.microsoftonline.com/{directory-id}/oauth2/v2.0/token using the client_credentials grant with scope https://graph.microsoft.com/.default, then calls Graph with ConsistencyLevel: eventual. Before the checks run it prefetches two resources and reuses them: /identity/conditionalAccess/policies and /policies/authorizationPolicy. Collection endpoints follow @odata.nextLink for up to 10 pages.

In the dashboard. Posture tab, Microsoft Entra ID provider tab, expand Entra ID configuration.

FieldGoes toStored as
Directory IDentra_tenant_id on shield_tenantsClear text.
Application client IDentra_client_id on shield_tenantsClear text.
Client secretentra_client_secret_encrypted on shield_tenantsAES-256-GCM under the server-held SHIELD_ENCRYPTION_KEY.

Press Save. All three fields are required on every save.

{ "action": "save_entra_config", "tenant_id": "<tenant uuid>", "entra_tenant_id": "<directory uuid>", "entra_client_id": "<application uuid>", "entra_client_secret": "<secret>" }

A successful save answers {"ok": true} and writes a save_entra_config audit row. A missing field answers 400 with entra_tenant_id, entra_client_id, and entra_client_secret are all required. If the credentials are wrong the scan itself answers 400 with Token acquisition failed and the status and body Microsoft returned.

Required Microsoft Graph permissions

Grant these as application permissions with admin consent. The scan never writes.

PermissionChecks that need it
Policy.Read.AllConditional Access policies, the authorization policy, security defaults, cross-tenant access, and the authentication flows policy.
AuthenticationMethod.Read.AllThe FIDO2, Microsoft Authenticator, SMS and password method configurations.
Directory.Read.AllDirectory roles and their members.
RoleManagement.Read.DirectoryThe Global Administrator role and its membership count.
User.Read.AllThe guest user count and the disabled account list.
AuditLog.Read.AllSign-in log and directory audit log availability.
RoleEligibilitySchedule.Read.DirectoryPrivileged Identity Management eligibility, which also needs an Entra ID P2 licence.
SecurityEvents.Read.AllMicrosoft Secure Score, which also needs a Microsoft 365 Defender or equivalent licence.

Anything not granted turns its checks into skipped findings that name the missing permission in their remediation, and skipped checks are left out of the score.

Four checks use the Graph beta endpoint: the self-service password reset probe (/policies/authenticationFlowsPolicy and the password method configuration), the banned-password probe (a directory setting plus the password method configuration), and Privileged Identity Management eligibility (/roleManagement/directory/roleEligibilityScheduleInstances). Microsoft does not expose a clean self-service password reset signal to an app-only token, so that check reports a warning when it cannot confirm the setting and asks you to verify it in the admin center.

Checks by category

25 checks run on every scan, in ten categories.

CategoryChecksWhat they read
mfa5A Conditional Access policy that requires MFA for all users, one that targets admin roles, and whether FIDO2, Microsoft Authenticator and SMS are enabled as methods.
conditional_access3Whether any policy is enabled, named locations, and whether any policy requires a compliant or hybrid-joined device.
admin3Global Administrator count against a threshold of 5, whether users may consent to apps, and Privileged Identity Management eligibility.
authentication3Legacy authentication blocked by policy, the security defaults state read against whether Conditional Access is in use, and cross-tenant access settings.
observability3Sign-in logs, directory audit logs and Microsoft Secure Score readability.
risk_detection2Whether any enabled policy evaluates sign-in risk or user risk levels.
lifecycle2Guest user count and disabled account count.
password2Self-service password reset and a custom banned password list.
session1Whether a policy sets sign-in frequency or persistent browser controls.
protocol1Whether a policy configures continuous access evaluation or token protection.

Admin role targeting is matched against four well-known role template ids: Global Administrator, Security Administrator, Authentication Administrator and Privileged Role Administrator. The security defaults check is state-aware: defaults enabled with no Conditional Access policies passes, and so does defaults disabled while policies exist; the two other combinations warn.

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. Entra's heaviest checks are Conditional Access existence, MFA for all users and FIDO2 at 8 each, then MFA for admin roles at 7.

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 client secret is encrypted, with AES-256-GCM under SHIELD_ENCRYPTION_KEY, and it is decrypted inside the function for the length of one scan. Encryption is mandatory: a save fails rather than storing plaintext if the key is missing. The directory ID and client ID stay in clear text because the token request needs them. Column-level SELECT on entra_client_secret_encrypted is revoked from the authenticated and anon roles.

Findings. Evidence holds counts and policy states such as admin_count, guest_count, mfa_policies or the state of an authentication method. Graph user objects are counted inside the function and discarded.

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. A completed scan writes a run_entra_id_posture_scan row to shield_audit_log with the provider, score, check counts and scan id. A failed scan writes the same verb with result set to failure.

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 Microsoft Entra ID, 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-entra-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.
400Entra ID configuration not found. Configure your Tenant ID, Client ID, and Client Secret first.No credentials are stored for this tenant.
400Token acquisition failed (...)Microsoft rejected the client credentials. The status and body are included.
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.

Okta and Entra ID compared

The two modules share the runner, the tables and the scoring formula, so their scores are comparable. What differs is where each one looks.

DimensionOktaEntra ID
CredentialsOkta domain plus an API token sent as SSWSDirectory ID, client ID and secret exchanged for a Graph token
MFA evidenceThe org factor list and MFA enrollment policiesAuthentication method configurations and Conditional Access grant controls
Admin countMembers of the built-in SUPER_ADMIN groupMembers of the Global Administrator directory role
Session controlsSign-on policy rulesConditional Access session controls
Category emphasismfa and lifecycle, 5 checks eachmfa 5, then conditional_access, admin, authentication and observability at 3 each
Scan row provideroktaentra_id

All eight providers are side by side in the ISPM cross-provider comparison.

Related from the blog