Moving an organization to passkeys without breaking access

A phased roadmap, the SMS/voice retirement deadlines, metrics that prove progress, and a repeatable readiness report

MFA to Passkeys in Microsoft Entra ID series · Part 7 of 7

Parts 1 to 6 each covered one piece of the work: the threat model, the credential choice, enforcement, enrollment, session protection, and privileged access. This final part puts them in order.

Moving to passkeys takes more than changing an authentication method setting. The programme touches every user, the service desk, endpoint management, and incident response. A planned rollout cuts AiTM phishing risk sharply without a wave of lockouts. A rushed rollout, run against Microsoft's deadlines, floods the help desk with recovery calls, and attackers exploit that confusion.

The clock: where we are at Q4 2026

From Microsoft Learn: passkeys by default and SMS/voice retirement:

  • 1 Sep 2026 (done): passkeys became the default. SMS/voice-enabled users were auto-enabled for passkeys, and the registration campaign moved to Microsoft managed.
  • 18 Sep 2026 (done): customer-managed telecom providers became available to review in the Microsoft Security Store.
  • 30 Oct 2026 (this month): customer-managed telecom providers can be selected and configured.
  • 1 Feb 2027 (upcoming): Microsoft-provided SMS/voice retires for everyone except Global Administrators and external users. SMS/voice-only users get a blocking passkey registration prompt, with no opt-out.
  • 1 Jul 2027 (upcoming): the same applies to Global Administrators and external users.

The auto-enablement has already happened, and you have roughly four months until the first hard deadline.

Aim to have every user in scope registered and using a passkey well before 1 February 2027. Then Microsoft's blocking prompt reaches nobody who hasn't already gone through your own supported, communicated process.

The example plan assumes you start in the first week of October, so it's tighter than it would have been over the summer. If your start slips, shorten the pilot waves rather than enrollment and recovery, and use customer-managed telecom as a bridge for a documented population instead of rushing workforce enforcement.

The seven phases

Microsoft's deployment guide frames the same journey as onboarding, then enforcement, per persona:

Microsoft's planning phases for phishing-resistant passwordless deployment

Phase 1: Baseline (Part 1)

  • Run Export-EptReadinessReport (below) and keep the output as your "before" picture.
  • Run Microsoft's SMS/voice usage analyzer (Global Reader, Authentication Policy Administrator or Security Reader). Per the FAQ, any non-zero result means you're in scope.
  • Keep three lists separate: users registered for telephony, users policy-enabled for telephony, and users who recently used telephony.
  • Inventory the special populations: shared devices, kiosks, frontline workers without company phones, VDI and jump hosts, contractors, guests (no passkey support until later in 2026), and service accounts set up as user accounts.

Phase 2: Target architecture and policy hygiene (Parts 2 and 3)

Finish the Authentication methods policy migration. In Migration in progress, both the legacy MFA/SSPR settings and the new policy are honored. In Migration complete, only the Authentication methods policy counts, so there's one source of truth. The change is reversible.

Manage migration: start the migration

Then build the rest of the architecture:

  • Personas and passkey profiles for Tier 0, sensitive, workforce, and frontline users (Part 2).
  • The built-in Phishing-resistant MFA authentication strength, plus a custom Tier 0 strength limited to approved AAGUIDs (Part 3).
  • Conditional Access policies in report-only mode: CA010/011 (device code and authentication transfer), CA100 (admins), and CA110 (pilot).
  • An exception register. Every exception needs an owner and an expiry date, and none are permanent.

Phase 3: Enrollment and recovery (Part 4)

  • Set up the TAP policy (one-time, short lifetime) and the Register security information Conditional Access policy.
  • Document identity verification per tier, and train the service desk before nudges start.
  • Decide on the registration campaign: keep Microsoft managed (unlimited snoozes) during the pilot, and switch to Enabled with limited snoozes for the push to completion.
  • Test the four hard journeys: new hire, replacement device, lost everything, and no smartphone.

Phase 4: Administrators first (Part 6)

  • Issue two attested security keys per admin. Register them in person or through dual-control TAP.
  • Enforce CA100/CA101 once Get-EptPrivilegedUser | Test-EptPhishResistantCoverage shows every admin as Covered.
  • Remove phishable methods from the admin persona. Set up break-glass passkeys and sign-in alerts.

Phase 5: Representative pilot waves

  • Include every OS/browser combination you support, BYOD and corporate devices, remote and office staff, and at least one team of heavy Teams Rooms or shared-device users.
  • For each wave, communicate, nudge, and measure registration. Then run the policy in report-only mode, enforce it, and measure usage.
  • Microsoft recommends a communications cadence of 60, 45, 30, 15, 7 and 1 days before enforcement.

Microsoft's Passwordless workbook can list who is ready for enforcement per platform:

Phase 6: Workforce enforcement

  • Enforce by OS group, following Microsoft's recommendation of one Conditional Access policy per platform.
  • Move the campaign to Enabled with limited snoozes for the remaining stragglers.
  • Retire SMS/voice from the Authentication methods policy group by group as usage drops to zero. Don't leave telephony enabled "just in case". Telephony is the fallback attackers use.

Phase 7: Sessions and continuous hardening (Part 5)

  • Require compliant devices for sensitive apps. Pilot token protection for Exchange, SharePoint and Teams.
  • Update incident-response runbooks: contain, remove persistence, reset, revoke, verify.
  • Schedule monthly Export-EptReadinessReport runs and review the trend.

If you must keep SMS/voice for a while

Microsoft offers two ways to buy time. Treat each one as a deliberate decision:

  1. Customer-managed telecom through the Microsoft Security Store (configurable from 30 Oct 2026). Users migrated to a customer provider before the retirement date don't get the blocking prompt. It carries per-provider costs, and it keeps a phishable, SIM-swappable method alive. Use it only as a time-limited bridge for a documented population.
  2. Temporary opt-out from passkey auto-enablement. The opt-out exists only from 1 Sep 2026 to 1 Feb 2027, so the window is open now and closes on 1 February. The toolkit deliberately doesn't wrap this in a function. The setting is beta-only and missing from the Graph reference docs, so opting out should be a conscious, reviewed decision:
# Requires Policy.ReadWrite.AuthenticationMethod. Beta endpoint: behaviour may change.
# This defers passkey auto-enablement only. There is NO opt-out from the 1 Feb 2027 enforcement.
Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes 'Policy.ReadWrite.AuthenticationMethod'
Invoke-MgGraphRequest -Method PATCH -Uri 'https://graph.microsoft.com/beta/policies/authenticationmethodspolicy' `
    -Body (@{ optOutSettings = @{ passkeyDynamicMigration = $true } } | ConvertTo-Json) -ContentType 'application/json'

Measure what matters

Registration counts are a vanity metric on their own. Track these together:

Metric Source Target
% users with a phishing-resistant method registered Measure-EptMfaPosture > 95% of in-scope users
% MFA sign-ins using a phishing-resistant method Measure-EptSignInMethod Trending to > 90%
Admins not Covered Test-EptPhishResistantCoverage 0
Telephony-only users Measure-EptMfaPosture (TelephonyOnlyCount) 0 before 1 Feb 2027
Enforced phishing-resistant CA policies / device code blocked Get-EptConditionalAccessPolicy Per plan
Recovery tickets and TAPs issued per week Service desk tickets and Get-EptAuthMethodAuditEvent Falling after each wave
Exceptions past their expiry Exception register 0

Hands-on: one command for the whole picture

Export-EptReadinessReport runs every read-only assessment from the series and writes a dated evidence folder. Each section runs in its own error boundary. If the tenant lacks P2 for PIM eligibility, or you forgot a scope, the function records that section as an error in summary.json and the rest of the report still completes.

function Export-EptReadinessReport {
    <#
    .SYNOPSIS
        Runs the read-only assessments from the series and writes a dated evidence folder plus a summary.
    .DESCRIPTION
        Orchestrates the other toolkit functions. It is read-only: nothing in the tenant is changed.
          * registration.csv          Get-EptUserRegistration (all users)
          * posture.csv               Measure-EptMfaPosture, overall and by IsAdmin
          * method-policy.csv         Get-EptAuthMethodPolicy
          * passkey-profiles.csv      Get-EptPasskeyProfile
          * conditional-access.csv    Get-EptConditionalAccessPolicy
          * admins.csv                Get-EptPrivilegedUser
          * admin-coverage.csv        Test-EptPhishResistantCoverage for every admin
          * signin-usage.csv          Measure-EptSignInMethod (unless -SkipSignIns)
          * summary.json              The headline numbers, for trending run over run

        Each section runs independently. If one fails (missing licence, scope or role), the error is
        recorded in summary.json and the remaining sections still run.

        Reports contain personal data (UPNs, IPs). Store them in approved locations only.

        Scopes: AuditLog.Read.All, Policy.Read.All, RoleManagement.Read.Directory, GroupMember.Read.All.
    .EXAMPLE
        Export-EptReadinessReport -Path ./reports -SignInDays 7
    .EXAMPLE
        # Trend the headline numbers across monthly runs
        Get-ChildItem ./reports -Filter summary.json -Recurse | Get-Content -Raw | ConvertFrom-Json |
            Sort-Object GeneratedUtc | Format-Table GeneratedUtc, PhishingResistantRegisteredPct, PhishingResistantSignInPct, AdminsNotCovered
    #>
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = '-SignInDays is used inside the Sign-in usage step scriptblock.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [ValidateRange(1, 30)]
        [int] $SignInDays = 7,

        [switch] $SkipSignIns
    )

    Assert-EptGraphConnection -RequiredScope 'AuditLog.Read.All', 'Policy.Read.All', 'RoleManagement.Read.Directory', 'GroupMember.Read.All'
    $tenant = (Get-MgContext).TenantId
    $folder = Join-Path $Path ("{0}-{1}" -f (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss'), $tenant.Substring(0, 8))
    New-Item -ItemType Directory -Path $folder -Force | Out-Null

    $summary = [ordered]@{ GeneratedUtc = (Get-Date).ToUniversalTime().ToString('o'); TenantId = $tenant; Errors = @() }
    $csv = { param($data, $name) $data | Export-Csv -Path (Join-Path $folder $name) -NoTypeInformation -Encoding utf8 }

    # Run one section; record failures and keep going.
    $step = {
        param([string] $name, [scriptblock] $action)
        Write-Progress -Activity 'EntraPasskeyToolkit readiness report' -Status $name
        try { & $action }
        catch {
            Write-Warning "$name failed: $($_.Exception.Message)"
            $summary.Errors += "${name}: $($_.Exception.Message)"
        }
    }

    & $step 'Registration' {
        $reg = @(Get-EptUserRegistration)
        & $csv ($reg | Select-Object -Property *,
            @{ n = 'Methods'; e = { $_.MethodsRegistered -join ';' } },
            @{ n = 'Phishable'; e = { $_.PhishableMethods -join ';' } } -ExcludeProperty MethodsRegistered, PhishableMethods) 'registration.csv'
        $overall = $reg | Measure-EptMfaPosture
        & $csv (@($overall) + @($reg | Measure-EptMfaPosture -GroupBy IsAdmin)) 'posture.csv'
        $summary.Users = $overall.Users
        $summary.PhishingResistantRegisteredPct = $overall.PhishingResistantPct
        $summary.TelephonyOnlyUsers = $overall.TelephonyOnlyCount
        $summary.UsersWithoutMfa = $overall.NoMfaCount
    }

    & $step 'Method policy' {
        $policy = @(Get-EptAuthMethodPolicy)
        & $csv ($policy | Select-Object Method, State, MigrationState, CampaignState, CampaignMethod,
            @{ n = 'Targets'; e = { $_.Targets -join ';' } }, @{ n = 'Excluded'; e = { $_.Excluded -join ';' } }) 'method-policy.csv'
        $summary.MigrationState = ($policy | Select-Object -First 1).MigrationState
        $summary.SmsOrVoiceEnabled = [bool]($policy | Where-Object { $_.Method -in 'Sms', 'Voice' -and $_.State -eq 'enabled' })
    }

    & $step 'Passkey profiles' {
        & $csv (Get-EptPasskeyProfile | Select-Object ProfileName, PasskeyTypes, Attestation, KeyRestriction,
            @{ n = 'Aaguids'; e = { $_.Aaguids -join ';' } }, @{ n = 'TargetIds'; e = { $_.TargetIds -join ';' } }) 'passkey-profiles.csv'
    }

    & $step 'Conditional Access' {
        $ca = @(Get-EptConditionalAccessPolicy)
        & $csv ($ca | Select-Object Name, State, StrengthName, RequiresMfa, RequiresPhishResistant, BlocksDeviceCode,
            @{ n = 'IncludeUsers'; e = { $_.IncludeUsers -join ';' } }, @{ n = 'IncludeGroups'; e = { $_.IncludeGroups -join ';' } },
            @{ n = 'IncludeRoles'; e = { $_.IncludeRoles -join ';' } }, @{ n = 'ExcludeGroups'; e = { $_.ExcludeGroups -join ';' } }) 'conditional-access.csv'
        $summary.DeviceCodeBlockedEnforced = [bool]($ca | Where-Object { $_.BlocksDeviceCode -and $_.State -eq 'enabled' })
        $summary.PhishResistantPoliciesEnforced = @($ca | Where-Object { $_.RequiresPhishResistant -and $_.State -eq 'enabled' }).Count
    }

    & $step 'Privileged users' {
        $admins = @(Get-EptPrivilegedUser)
        & $csv $admins 'admins.csv'
        $coverage = @($admins | Where-Object PrincipalType -eq 'user' | Test-EptPhishResistantCoverage)
        & $csv $coverage 'admin-coverage.csv'
        $summary.Admins = $admins.Count
        $summary.AdminsNotCovered = @($coverage | Where-Object Verdict -ne 'Covered').Count
    }

    if (-not $SkipSignIns) {
        & $step 'Sign-in usage' {
            $usage = @(Get-EptSignInMethod -Days $SignInDays | Measure-EptSignInMethod)
            & $csv ($usage | Select-Object -Property *,
                @{ n = 'TelephonyUserList'; e = { $_.TelephonyUsers -join ';' } } -ExcludeProperty TelephonyUsers) 'signin-usage.csv'
            $summary.SignInDays = $SignInDays
            $summary.PhishingResistantSignInPct = ($usage | Select-Object -First 1).PhishingResistantPct
            $summary.TelephonySignInPct = ($usage | Select-Object -First 1).TelephonyPct
        }
    }

    Write-Progress -Activity 'EntraPasskeyToolkit readiness report' -Completed
    $summary.Folder = (Resolve-Path $folder).Path
    $summary | ConvertTo-Json -Depth 5 | Set-Content -Path (Join-Path $folder 'summary.json') -Encoding utf8
    [pscustomobject]$summary
}

Source: EntraPasskeyToolkit/Public/Report.ps1

Import-Module ./EntraPasskeyToolkit/EntraPasskeyToolkit.psd1
Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes `
    'AuditLog.Read.All', 'Policy.Read.All', 'RoleManagement.Read.Directory', 'GroupMember.Read.All'

$result = Export-EptReadinessReport -Path ./reports -SignInDays 7
$result | Format-List Users, PhishingResistantRegisteredPct, PhishingResistantSignInPct, TelephonyOnlyUsers, AdminsNotCovered, MigrationState, DeviceCodeBlockedEnforced, Errors

# Month-over-month trend
Get-ChildItem ./reports -Filter summary.json -Recurse |
    ForEach-Object { Get-Content $_ -Raw | ConvertFrom-Json } |
    Sort-Object GeneratedUtc |
    Format-Table GeneratedUtc, PhishingResistantRegisteredPct, PhishingResistantSignInPct, TelephonyOnlyUsers, AdminsNotCovered

Schedule it in Azure Automation or a GitHub Actions / Azure DevOps runner using an app registration with a certificate and application permissions (AuditLog.Read.All, Policy.Read.All, RoleManagement.Read.Directory, GroupMember.Read.All). The toolkit skips the delegated-scope check for app-only sessions and lets Graph enforce permissions. Store the output in an access-controlled location, because it contains UPNs and IP addresses.

The end state

A finished passkey programme needs more than every user registering a passkey. It looks like this:

  • Phishing-resistant sign-in is required for every user and resource in scope, with no phishable fallback.
  • Admins use attested, device-bound keys from dedicated accounts, and break-glass accounts are tested and monitored.
  • Enrollment and recovery run through verified, logged, time-boxed processes that a caller can't talk their way around.
  • Sessions are tied to trusted devices for sensitive apps, and incident response knows how to contain token theft.
  • You run the readiness report every month. The phishing-resistant sign-in percentage keeps rising, and telephony-only users, uncovered admins, and expired exceptions stay at zero.

Use the Migration Checklist as your working plan, and the EntraPasskeyToolkit to prove each box is ticked.

References

Phishing-resistant MFA where it matters most, and finding the privileged identities your role report misses

MFA to Passkeys in Microsoft Entra ID series · Part 6 of 7

If you can only do one thing from this series this quarter, focus on doing it for your administrators. A phished Global Administrator account gives the attacker the whole tenant. The attacker can:

  • rewrite Conditional Access,
  • register their own credentials on any account,
  • grant themselves application permissions that no user MFA ever touches,
  • turn off your logging.

Microsoft's guidance requires phishing-resistant MFA for administrators. Its phishing-resistant deployment guide puts admins in their own persona, with FIDO2 security keys as the recommended portable credential. This part turns that guidance into a working program. It starts with the hardest step, which is working out who your admins are.

Who is privileged? More people than your role report says

Where privilege lives Example of privilege Covered by Entra role reports?
Entra directory roles, active Global Administrator, Privileged Role Administrator… Yes
Entra directory roles, PIM-eligible Can activate Global Admin in minutes Only if you query eligibility
Role-assignable groups Membership grants a role Only if you expand groups
Application owners Can add a secret to an app holding Directory.ReadWrite.All No
Azure RBAC Owner on the subscription running your domain controllers No
Intune RBAC Can push a script to every device No
Defender RBAC Live response: run commands on endpoints No
Purview role groups eDiscovery: export any mailbox, including Copilot interactions No
Exchange / SharePoint RBAC Mailbox full access, site collection admin No

Build the privileged cohort around capabilities, not job titles. The toolkit covers the first four rows. The others need their own product's RBAC review, and you should list those reviews in the project plan so nobody assumes the Entra report covered them.

Admin account prerequisites

Phishing-resistant MFA on the wrong account design still leaves gaps. Microsoft's privileged access guidance boils down to:

  1. Separate admin accounts. Make them cloud-only, with no mailbox, no Teams and no web browsing. Nobody reads phishing email with the account that owns Conditional Access.
  2. Just-in-time access with PIM. Make role assignments eligible instead of permanent for everyone except break-glass accounts. Require approval for Tier 0 roles. Also require re-authentication on activation, using a Conditional Access authentication context that demands your Tier 0 strength.
  3. Privileged Access Workstations (PAW). If you can't deploy PAWs, use compliant, dedicated admin devices at minimum. Require a compliant device for admin portals.
  4. Device-bound, attested passkeys. Issue security keys from an approved AAGUID list and enforce attestation (Part 2). Give each admin two keys, stored separately.
  5. No phishable methods on admin accounts. Remove SMS, voice, OATH and push from the admin persona in the authentication methods policy once keys are registered. Even unused, they are a help-desk social-engineering target (Part 4).
  6. Target custom roles through groups. Conditional Access role targeting honors only built-in roles. If you use custom or AU-scoped roles, target a group of those admins instead.

Emergency access (break-glass) accounts

Microsoft's emergency access guidance now covers phishing-resistant methods:

  • Two or more cloud-only accounts on the *.onmicrosoft.com domain, with Global Administrator assigned as permanent active in PIM, not eligible.
  • Passwordless, phishing-resistant authentication, either "Passkey (FIDO2) (Recommended)" or certificate-based authentication. Use methods that differ from your normal admin accounts, so a problem with one method or one key vendor can't lock out both.
  • Mandatory MFA applies to break-glass accounts too. There is no exemption from the Azure and admin-portal MFA enforcement, which is another reason to give them passkeys.
  • Use a dedicated group to exclude them from Conditional Access policies that can block sign-in. Report-only policies don't need the exclusion.
  • Alert on every sign-in (threshold > 0) with Azure Monitor or Sentinel, and validate the accounts at least every 90 days.

Azure Monitor alert logic for emergency account sign-ins

Store the break-glass security keys in two physically separate safes, with a documented, dual-control retrieval process. Test that process, not just the keys.

The admin policy set

Building on Part 3's reference design:

Policy Target Control
CA100 14 privileged roles (Microsoft template list) Phishing-resistant MFA strength, or custom Tier 0 strength
CA101 Tier 0 admins group (covers custom and AU-scoped roles) Custom Tier 0 strength (approved AAGUIDs)
CA102 Admin portals (Microsoft Admin Portals app) for all admins Compliant device
CA103 PIM activation (authentication context c1) Tier 0 strength, sign-in frequency every time

Hidden admins: application owners and workload identities

An application owner can add a client secret or certificate to that app. If the app holds high-privilege application permissions (for example Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, Mail.ReadWrite), the owner can mint app-only tokens that:

  • never involve a user sign-in,
  • never meet a passkey prompt,
  • never trigger user-based Conditional Access.

User MFA doesn't turn an app-only token request into an interactive sign-in. Treat these owners as privileged:

  • inventory app registrations, their owners and their credentials;
  • remove owners who don't need to manage credentials, and prefer certificates or federated credentials over secrets;
  • review high-privilege application permissions and consent grants on the enterprise app (service principal) side;
  • consider Conditional Access for workload identities (location- and risk-based) for the most powerful single-tenant apps.

Hands-on: build the privileged cohort and test it

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes `
    'RoleManagement.Read.Directory', 'GroupMember.Read.All', 'AuditLog.Read.All', 'Policy.Read.All', `
    'UserAuthenticationMethod.Read.All', 'Application.Read.All'

Every privileged user, active and eligible: Get-EptPrivilegedUser

This reads active role assignments and PIM eligibility (roleEligibilityScheduleInstances), expands role-assignable groups to their transitive user members, and returns one object per user with their roles, assignment types, and the groups that granted them. Eligibility needs Entra ID P2. If that call fails, the function warns and continues with active assignments.

function Get-EptPrivilegedUser {
    <#
    .SYNOPSIS
        Lists users who hold Entra directory roles, both active and PIM-eligible, one object per user.
    .DESCRIPTION
        Reads active assignments (GET /roleManagement/directory/roleAssignments) and, unless
        -ActiveOnly is used, PIM eligibility (GET /roleManagement/directory/roleEligibilityScheduleInstances).
        Role-assignable groups are expanded to their transitive user members so that group-based
        admins are not missed. Service principals holding roles are reported with -IncludeServicePrincipal.

        Output binds by property name (UserPrincipalName, Id) to the other Ept functions, which makes
        this the natural start of an admin posture pipeline.

        This covers Entra directory roles only. Azure RBAC, Intune, Defender, Purview and
        application ownership are separate control planes: review them separately.

        Requires: RoleManagement.Read.Directory (plus GroupMember.Read.All to expand groups) and
        a role such as Global Reader or Privileged Role Administrator. Eligibility needs Entra ID P2.
    .PARAMETER RoleName
        Only return holders of these roles (wildcards allowed), e.g. 'Global Administrator','*Authentication*'.
    .EXAMPLE
        Get-EptPrivilegedUser | Get-EptUserRegistration | Where-Object { -not $_.HasPhishingResistant }
    .EXAMPLE
        Get-EptPrivilegedUser -RoleName 'Global Administrator' | Format-Table UserPrincipalName, Roles, AssignmentTypes
    #>
    [CmdletBinding()]
    [OutputType('Ept.PrivilegedUser')]
    param(
        [string[]] $RoleName = '*',
        [switch] $ActiveOnly,
        [switch] $IncludeServicePrincipal
    )

    Assert-EptGraphConnection -RequiredScope 'RoleManagement.Read.Directory'
    $root = 'https://graph.microsoft.com/v1.0/roleManagement/directory'

    $sources = @(@{ Type = 'Active'; Uri = "$root/roleAssignments?`$expand=principal,roleDefinition" })
    if (-not $ActiveOnly) {
        $sources += @{ Type = 'Eligible'; Uri = "$root/roleEligibilityScheduleInstances?`$expand=principal,roleDefinition" }
    }

    $byPrincipal = @{}
    foreach ($source in $sources) {
        try {
            $assignments = @(Invoke-EptGraphRequest -Uri $source.Uri)
        }
        catch {
            if ($source.Type -eq 'Eligible') {
                Write-Warning "Could not read PIM eligibility (P2 licence or permission missing?): $($_.Exception.Message)"
                continue
            }
            throw
        }

        foreach ($a in $assignments) {
            $role = $a.roleDefinition.displayName
            if (-not ($RoleName | Where-Object { $role -like $_ })) { continue }

            $principal = $a.principal
            $kind = $principal.'@odata.type' -replace '#microsoft.graph.', ''
            $members = switch ($kind) {
                'user' { , $principal }
                'group' {
                    Invoke-EptGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$($principal.id)/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName,displayName,accountEnabled"
                }
                'servicePrincipal' { if ($IncludeServicePrincipal) { , $principal } }
            }

            foreach ($m in $members) {
                if (-not $byPrincipal.ContainsKey($m.id)) {
                    $byPrincipal[$m.id] = [pscustomobject]@{
                        PSTypeName        = 'Ept.PrivilegedUser'
                        Id                = $m.id
                        UserPrincipalName = if ($m.userPrincipalName) { $m.userPrincipalName } else { $m.appId }
                        DisplayName       = $m.displayName
                        PrincipalType     = $kind
                        AccountEnabled    = $m.accountEnabled
                        Roles             = [System.Collections.Generic.SortedSet[string]]::new()
                        AssignmentTypes   = [System.Collections.Generic.SortedSet[string]]::new()
                        ViaGroup          = [System.Collections.Generic.SortedSet[string]]::new()
                    }
                }
                $entry = $byPrincipal[$m.id]
                [void]$entry.Roles.Add($role)
                [void]$entry.AssignmentTypes.Add($source.Type)
                if ($kind -eq 'group') { [void]$entry.ViaGroup.Add($principal.displayName) }
            }
        }
    }

    $byPrincipal.Values | ForEach-Object {
        # Flatten sets to strings so the objects export cleanly to CSV.
        $_.Roles = @($_.Roles) -join '; '
        $_.AssignmentTypes = @($_.AssignmentTypes) -join '; '
        $_.ViaGroup = @($_.ViaGroup) -join '; '
        $_
    } | Sort-Object UserPrincipalName
}

Source: EntraPasskeyToolkit/Public/Privileged.ps1

App registrations, owners and credentials: Get-EptAppCredentialOwner

This returns credential metadata only (counts and expiry). It never reads secret values.

function Get-EptAppCredentialOwner {
    <#
    .SYNOPSIS
        Inventories app registrations with their owners and credential metadata (never secret values).
    .DESCRIPTION
        An owner of an app registration can add credentials to it. If the app holds powerful
        application permissions, its owners are effectively privileged even without a directory role,
        and user MFA does not apply to the app-only tokens that credential can obtain.
        Pipe the Owners into Get-EptUserRegistration to check how those people authenticate.

        Requires: Application.Read.All.
    .EXAMPLE
        Get-EptAppCredentialOwner | Where-Object OwnerCount -eq 0
    .EXAMPLE
        Get-EptAppCredentialOwner | Select-Object -ExpandProperty Owners -Unique | Get-EptUserRegistration
    #>
    [CmdletBinding()]
    [OutputType('Ept.AppCredentialOwner')]
    param(
        [ValidateRange(0, 3650)]
        [int] $ExpiringWithinDays = 30
    )
    Assert-EptGraphConnection -RequiredScope 'Application.Read.All'
    $now = (Get-Date).ToUniversalTime()

    Invoke-EptGraphRequest -Uri 'https://graph.microsoft.com/v1.0/applications?$select=id,appId,displayName,passwordCredentials,keyCredentials&$expand=owners($select=id,userPrincipalName)' |
        ForEach-Object {
            $creds = @($_.passwordCredentials) + @($_.keyCredentials) | Where-Object { $_ }
            $ends = $creds | ForEach-Object { [datetime]$_.endDateTime }
            [pscustomobject]@{
                PSTypeName         = 'Ept.AppCredentialOwner'
                AppId              = $_.appId
                DisplayName        = $_.displayName
                OwnerCount         = @($_.owners).Count
                Owners             = @($_.owners | Where-Object userPrincipalName | ForEach-Object userPrincipalName)
                SecretCount        = @($_.passwordCredentials).Count
                CertificateCount   = @($_.keyCredentials).Count
                ExpiredCredentials = @($ends | Where-Object { $_ -lt $now }).Count
                ExpiringSoon       = @($ends | Where-Object { $_ -ge $now -and $_ -lt $now.AddDays($ExpiringWithinDays) }).Count
            }
        }
}

Source: EntraPasskeyToolkit/Public/Privileged.ps1

The admin posture report

The same $admins cohort feeds every check below:

$admins = Get-EptPrivilegedUser
$admins | Format-Table UserPrincipalName, Roles, AssignmentTypes, ViaGroup -Wrap

# 1. How do admins authenticate? (registration)
$reg = $admins | Get-EptUserRegistration
$reg | Measure-EptMfaPosture
$reg | Where-Object { -not $_.HasPhishingResistant -or $_.PhishableMethods } |
    Format-Table UserPrincipalName, StrongestMethod, @{ n = 'Phishable'; e = { $_.PhishableMethods -join ', ' } }

# 2. What kind of passkeys do they hold? (Tier 0 = attested, device-bound only)
$admins | Get-EptUserPasskey |
    Where-Object { $_.PasskeyType -ne 'deviceBound' -or $_.AttestationLevel -ne 'attested' } |
    Format-Table UserPrincipalName, Authenticator, PasskeyType, AttestationLevel

# 3. Does an enforced phishing-resistant policy actually cover them? (Part 3)
$admins | Test-EptPhishResistantCoverage | Where-Object Verdict -ne 'Covered' |
    Format-Table UserPrincipalName, Verdict, PhishableMethods

# 4. How did they actually sign in over the last 14 days? (Part 5)
$admins | Get-EptSignInMethod -Days 14 -SuccessOnly |
    Where-Object { $_.AuthRequirement -eq 'multiFactorAuthentication' -and $_.MethodClass -ne 'PhishingResistant' } |
    Group-Object UserPrincipalName, MethodClass -NoElement

# 5. Any recent security-info changes on admin accounts? (Part 4)
$admins | Get-EptAuthMethodAuditEvent -Days 30 | Format-Table Time, Activity, Initiator, TargetUpn

Export everything for the review meeting:

$stamp = Get-Date -Format yyyyMMdd
$admins | Test-EptPhishResistantCoverage | Export-Csv "./admin-coverage-$stamp.csv" -NoTypeInformation
$admins | Get-EptUserPasskey | Export-Csv "./admin-passkeys-$stamp.csv" -NoTypeInformation

Hidden admins: owners of apps that deserve review

# Apps nobody owns, or with credentials expiring soon
Get-EptAppCredentialOwner -ExpiringWithinDays 30 |
    Where-Object { $_.OwnerCount -eq 0 -or $_.ExpiringSoon -gt 0 } |
    Format-Table DisplayName, OwnerCount, SecretCount, CertificateCount, ExpiringSoon

# How do app owners authenticate? Owners of apps with secrets are privileged in practice.
Get-EptAppCredentialOwner |
    Where-Object SecretCount -gt 0 |
    Select-Object -ExpandProperty Owners -Unique |
    Get-EptUserRegistration |
    Where-Object { -not $_.HasPhishingResistant } |
    Format-Table UserPrincipalName, StrongestMethod

This inventory covers app registrations in your tenant. It doesn't resolve which of those apps hold dangerous permissions. Cross-reference with the enterprise application's Permissions blade, or with Graph appRoleAssignments on the service principal, before you decide which owners belong in the Tier 0 cohort.

Break-glass verification

$breakGlass = 'bg-01@contoso.onmicrosoft.com', 'bg-02@contoso.onmicrosoft.com'

# Must be: phishing-resistant only, excluded from blocking policies, and rarely used
$breakGlass | Get-EptUserRegistration | Format-Table UserPrincipalName, StrongestMethod, MethodsRegistered
$breakGlass | Get-EptUserPasskey | Format-Table UserPrincipalName, Authenticator, AttestationLevel, Created
$breakGlass | Get-EptSignInMethod -Days 30 | Format-Table Time, AppDisplayName, IPAddress, MethodsUsed   # expect only your 90-day tests

Best practices

  1. Define "privileged" by capability, and include PIM-eligible users, group-assigned roles, and app owners.
  2. Use separate, cloud-only admin accounts with no mailbox, and sign in to them only from compliant or PAW devices.
  3. Issue attested, device-bound passkeys from an approved list, two per admin, and leave no phishable fallback methods.
  4. Put every role except break-glass in PIM, with authentication-context step-up on activation.
  5. Keep two or more break-glass accounts with passkeys or CBA that differ from normal admin methods, excluded from blocking policies, alerted on every sign-in, and tested every 90 days.
  6. Review workload identities, including owners, credentials, application permissions, and Conditional Access for workload identities.
  7. Run the admin posture report monthly and track the trend over time instead of relying on a one-time snapshot.

Next, Part 7: turning Parts 1–6 into a phased migration plan that finishes before Microsoft's SMS/voice retirement dates.

References

Session security, token protection, continuous access evaluation, and measuring what users ACTUALLY sign in with

MFA to Passkeys in Microsoft Entra ID series · Part 5 of 7

A passkey proves who the user is at the moment of sign-in. Then Entra ID hands out tokens so the user doesn't have to prove it again for every email, file and Teams message. Those tokens are what attackers want in the end, and some of the ways to steal them never touch the sign-in page.

This doesn't weaken the case for passkeys. It defines where passkeys stop and other controls take over.

Authentication and sessions are different problems

Artifact What it is Lifetime (typical) Stolen by
Session cookie (ESTSAUTH…) Browser session with Entra ID Hours to days; rolling AiTM proxy, infostealer
Refresh token Lets an app get new access tokens silently Up to 90 days, rolling Infostealer, malicious app, device code phishing
Primary Refresh Token (PRT) Device-level SSO token on joined/registered Windows, macOS, iOS Up to 14 days, renewed Malware with local admin / TPM bypass attempts
Access token Presented to Exchange, SharePoint, Graph… ~60–90 minutes (longer with CAE) Any of the above, or a compromised app

Match each attack to what stops it:

Attack Passkey stops it? Controls that help
AiTM proxy relays sign-in, steals the cookie Yes. The proxy can't complete a passkey ceremony Phishing-resistant strength (Part 3)
Device code phishing No. The user signs in on the real site Block device code flow (Part 3)
Infostealer copies browser cookies / refresh tokens No. The sign-in was legitimate Compliant device, token protection, EDR, CAE, detection
Malicious OAuth app consent No Admin consent workflow, app governance
Help desk registers the attacker's passkey No Registration controls (Part 4)

Layer 1: tie access to trusted devices

A Conditional Access policy that requires a compliant (Intune) or Microsoft Entra hybrid joined device for sensitive apps makes a stolen cookie much less useful. When it's replayed from the attacker's laptop, the device claim fails.

This is the single most effective control against token replay today. Its limits are coverage: BYOD, contractors, and browser access from unmanaged devices. Combine it with app-enforced restrictions or session controls for those populations.

Layer 2: token protection

Token protection is a Conditional Access session control, Require token protection for sign-in sessions. It cryptographically binds the sign-in session token to the device, so a token copied to another machine is rejected. It requires Entra ID P1.

Platform Native apps Browser apps
Windows Generally available Preview, supported web apps accessing Azure Resource Manager
macOS Generally available Preview, Azure Resource Manager web apps
iOS / iPadOS Generally available Not supported

As published on Microsoft Learn, September 2026. Some sections of the same page still label Apple platforms as preview, so check the current matrix before relying on it.

Supported resources for native apps:

  • Exchange Online
  • SharePoint Online
  • Microsoft Teams
  • On Windows only: Azure Virtual Desktop and Windows 365

Apple devices need macOS 14+ or iOS 16+ with the Enterprise SSO plug-in, on MDM-managed devices.

How to deploy it (deployment guide):

  • Target Exchange, SharePoint and Teams individually. Microsoft warns that selecting the whole Office 365 app group "might result in unintended failures".
  • Scope client apps to Mobile apps and desktop clients.
  • Run it in report-only first and read the Token Protection – Sign In Session field in the sign-in log. The status codes tell you why a request was Unbound:
    • 1002: no device state
    • 1003: device doesn't meet requirements
    • 1006: unsupported OS
    • 1008: client not integrated with the broker (WAM)

Sign-in log showing an unbound token protection session with status 1002

Token protection is valuable but not universal. It covers specific clients, platforms and resources. Plan it as one layer, not as "the fix for token theft".

Layer 3: continuous access evaluation and session lifetime

Continuous access evaluation (CAE) lets supporting resources (Exchange, SharePoint, Teams, Graph) react in near real time to critical events instead of waiting for a token to expire (CAE). The critical events are:

  • the account is deleted or disabled;
  • the password is changed or reset;
  • MFA is enabled for the user;
  • an administrator explicitly revokes all refresh tokens;
  • Identity Protection detects high user risk.

Critical event evaluation doesn't depend on Conditional Access policies, so it works in every tenant. SharePoint doesn't support user-risk events.

Sign-in frequency controls how often users must re-authenticate. The default is a rolling 90-day window. Shorter frequencies for high-risk apps or unmanaged devices limit how long a stolen token stays useful. Remember the known issue from Part 3: authentication strength and sign-in frequency can be satisfied at two different times.

Layer 4: detection and response

Keep monitoring after the passkey rollout:

  • Identity Protection risk detections, including anomalous token and token issuer anomalies, unfamiliar sign-in properties, and attacker-in-the-middle detections.
  • Defender for Endpoint and Defender XDR infostealer and credential-access alerts. Correlate them with the user's sign-ins.
  • Sign-ins whose method class changed. A user who has signed in with a passkey for months and suddenly satisfies MFA with SMS deserves a look.

Response order matters

If you revoke sessions first, an attacker who still has a registered method or a working credential signs in again. Revocation also leaves existing access tokens alone. If the resource doesn't support CAE, those tokens stay valid until they expire. And it doesn't revoke sessions for external users in your tenant.

Hands-on: measure real usage and respond

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome `
    -Scopes 'AuditLog.Read.All', 'Policy.Read.All'

Which methods do users actually sign in with? Get-EptSignInMethod

Registration reports (Part 1) tell you what users could use. Sign-in logs tell you what they did use.

The fields you need are authenticationDetails, authenticationProtocol, originalTransferMethod and tokenProtectionStatusDetails. They exist only in the beta sign-in API, so this function uses /beta/auditLogs/signIns. Pin the module version and retest after Graph changes.

Microsoft documents only some of the method strings that appear in authenticationDetails. The MethodClass column is therefore a heuristic. Run with -Verbose to see the raw strings in your tenant and adjust the patterns if needed.

function Get-EptSignInMethod {
    <#
    .SYNOPSIS
        Returns sign-ins with the authentication methods actually used, the protocol, and token-protection state.
    .DESCRIPTION
        Uses the BETA sign-in endpoint (GET /beta/auditLogs/signIns), because authenticationDetails,
        authenticationProtocol, originalTransferMethod and tokenProtectionStatusDetails are not
        exposed in v1.0. Beta APIs can change; pin a module version and re-test after upgrades.

        Each sign-in gets a MethodClass derived from the method names in authenticationDetails:
        PhishingResistant, Phishable, Telephony, SingleFactor or Unknown. Microsoft documents only a subset of
        these strings, so the classification is a heuristic. Spot-check new strings with -Verbose.

        By default only interactive user sign-ins are returned (the API default). Keep -Days small in
        large tenants, and use Log Analytics / Sentinel for anything beyond the API retention window.

        Requires: AuditLog.Read.All + Policy.Read.All (for Conditional Access details), Security Reader
        or Reports Reader, and Entra ID P1/P2.
    .EXAMPLE
        Get-EptSignInMethod -Days 7 | Measure-EptSignInMethod
    .EXAMPLE
        Get-EptSignInMethod -Days 30 -Protocol deviceCode | Group-Object UserPrincipalName, AppDisplayName | Sort-Object Count -Descending
    .EXAMPLE
        Get-EptPrivilegedUser | Get-EptSignInMethod -Days 14 | Where-Object MethodClass -ne 'PhishingResistant'
    #>
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = '-Protocol and -SuccessOnly are read inside the nested Invoke-Query helper.')]
    [CmdletBinding()]
    [OutputType('Ept.SignInMethod')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('UPN')]
        [string[]] $UserPrincipalName,

        [ValidateRange(1, 30)]
        [int] $Days = 1,

        # Client-side filter on authenticationProtocol, e.g. deviceCode, ropc, saml20, wsFederation.
        [string] $Protocol,

        [switch] $SuccessOnly
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'AuditLog.Read.All'
        $since = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
        $headers = @{ Prefer = 'include-unknown-enum-members' }   # needed to see authenticationTransfer

        function Get-MethodClass([string[]] $methods) {
            $joined = $methods -join '|'
            if (-not $joined) { return 'Unknown' }
            if ($joined -match 'passkey|FIDO|Windows Hello|certificate|Platform Credential|Secure Enclave') { return 'PhishingResistant' }
            if ($joined -match 'SMS|Text message|Voice|phone call') { return 'Telephony' }
            if ($joined -match 'Authenticator|OATH|notification|verification code|Temporary Access Pass|QR code') { return 'Phishable' }
            if ($joined -match 'Previously satisfied|Satisfied by token') { return 'SatisfiedByToken' }
            if ($joined -match '^Password$') { return 'SingleFactor' }
            'Unknown'
        }

        function Invoke-Query([string] $filter) {
            $uri = 'https://graph.microsoft.com/beta/auditLogs/signIns?$filter=' + [uri]::EscapeDataString($filter)
            Invoke-EptGraphRequest -Uri $uri -Headers $headers | ForEach-Object {
                $used = @($_.authenticationDetails | Where-Object succeeded | ForEach-Object authenticationMethod | Select-Object -Unique)
                Write-Verbose "$($_.userPrincipalName): methods '$($used -join ', ')'"
                $row = [pscustomobject]@{
                    PSTypeName             = 'Ept.SignInMethod'
                    Time                   = [datetime]$_.createdDateTime
                    UserPrincipalName      = $_.userPrincipalName
                    AppDisplayName         = $_.appDisplayName
                    ResourceDisplayName    = $_.resourceDisplayName
                    IPAddress              = $_.ipAddress
                    Country                = $_.location.countryOrRegion
                    Succeeded              = ($_.status.errorCode -eq 0)
                    ErrorCode              = $_.status.errorCode
                    AuthRequirement        = $_.authenticationRequirement
                    Protocol               = $_.authenticationProtocol
                    OriginalTransferMethod = $_.originalTransferMethod
                    MethodsUsed            = $used -join '; '
                    MethodClass            = Get-MethodClass $used
                    ConditionalAccess      = $_.conditionalAccessStatus
                    TokenProtection        = $_.tokenProtectionStatusDetails.signInSessionStatus
                    DeviceCompliant        = $_.deviceDetail.isCompliant
                    CorrelationId          = $_.correlationId
                }
                if ($Protocol -and $row.Protocol -ne $Protocol) { return }
                if ($SuccessOnly -and -not $row.Succeeded) { return }
                $row
            }
        }
        $anyUser = $false
    }
    process {
        foreach ($u in $UserPrincipalName) {
            if (-not $u) { continue }
            $anyUser = $true
            Invoke-Query ("createdDateTime ge $since and userPrincipalName eq '{0}'" -f ($u -replace "'", "''"))
        }
    }
    end {
        if (-not $anyUser) { Invoke-Query "createdDateTime ge $since" }
    }
}

Source: EntraPasskeyToolkit/Public/Session.ps1

The function requests Prefer: include-unknown-enum-members. Without that header, newer protocol values such as authenticationTransfer come back as unknownFutureValue.

The metric that matters: Measure-EptSignInMethod

It counts only successful sign-ins that required MFA, and reports what share used each class of method.

function Measure-EptSignInMethod {
    <#
    .SYNOPSIS
        Aggregates Get-EptSignInMethod output: how many successful MFA sign-ins used each class of method.
    .DESCRIPTION
        This is the usage metric that registration reports cannot give you. "80% registered a passkey"
        means little if 60% of MFA sign-ins still use push or SMS.
    .EXAMPLE
        Get-EptSignInMethod -Days 7 | Measure-EptSignInMethod
    .EXAMPLE
        Get-EptSignInMethod -Days 7 | Measure-EptSignInMethod -GroupBy AppDisplayName | Sort-Object PhishingResistantPct
    #>
    [CmdletBinding()]
    [OutputType('Ept.SignInMethodSummary')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSTypeName('Ept.SignInMethod')] $InputObject,

        [string] $GroupBy
    )
    begin { $rows = [System.Collections.Generic.List[object]]::new() }
    process {
        if ($InputObject.Succeeded -and $InputObject.AuthRequirement -eq 'multiFactorAuthentication') { $rows.Add($InputObject) }
    }
    end {
        $groups = if ($GroupBy) { $rows | Group-Object -Property $GroupBy } else { , @{ Name = 'All'; Group = $rows } }
        foreach ($g in $groups) {
            $set = @($g.Group); $n = $set.Count
            if (-not $n) { continue }
            $count = { param($c) @($set | Where-Object MethodClass -eq $c).Count }
            [pscustomobject]@{
                PSTypeName           = 'Ept.SignInMethodSummary'
                Group                = $g.Name
                MfaSignIns           = $n
                PhishingResistantPct = [math]::Round(100 * (& $count 'PhishingResistant') / $n, 1)
                PhishablePct         = [math]::Round(100 * (& $count 'Phishable') / $n, 1)
                TelephonyPct         = [math]::Round(100 * (& $count 'Telephony') / $n, 1)
                SatisfiedByTokenPct  = [math]::Round(100 * (& $count 'SatisfiedByToken') / $n, 1)
                DistinctUsers        = @($set.UserPrincipalName | Select-Object -Unique).Count
                TelephonyUsers       = @($set | Where-Object MethodClass -eq 'Telephony' | ForEach-Object UserPrincipalName | Select-Object -Unique)
            }
        }
    }
}

Source: EntraPasskeyToolkit/Public/Session.ps1

# Tenant-wide, last 7 days
Get-EptSignInMethod -Days 7 | Measure-EptSignInMethod | Format-List

# Per application: which apps still see phishable MFA?
Get-EptSignInMethod -Days 7 | Measure-EptSignInMethod -GroupBy AppDisplayName |
    Sort-Object PhishingResistantPct | Format-Table Group, MfaSignIns, PhishingResistantPct, PhishablePct, TelephonyPct

# Admins who satisfied MFA with anything other than a phishing-resistant method
Get-EptPrivilegedUser | Get-EptSignInMethod -Days 14 -SuccessOnly |
    Where-Object { $_.AuthRequirement -eq 'multiFactorAuthentication' -and $_.MethodClass -notin 'PhishingResistant', 'SatisfiedByToken' } |
    Format-Table Time, UserPrincipalName, AppDisplayName, MethodsUsed, IPAddress

# Token protection readiness: bound vs. unbound sessions by app
Get-EptSignInMethod -Days 3 | Where-Object TokenProtection |
    Group-Object AppDisplayName, TokenProtection -NoElement | Sort-Object Name

Put PhishingResistantPct from Measure-EptSignInMethod next to PhishingResistantPct from Measure-EptMfaPosture (Part 1). The difference between registered and used is the clearest progress metric a passkey programme has.

Sign-in log Authentication Details tab

Screenshot: Microsoft Learn. The Authentication Details tab in the portal is the source of the MethodsUsed column. Microsoft notes it "can initially show incomplete or inaccurate data" for very recent sign-ins.

Contain: Revoke-EptUserSession

This is a thin, pipeline-friendly wrapper for POST /users/{id}/revokeSignInSessions. It has a mandatory -Reason parameter, runs ShouldProcess at High impact, and returns one result object per user so you have evidence for the incident record.

function Revoke-EptUserSession {
    <#
    .SYNOPSIS
        Revokes a user's refresh tokens and session cookies (POST /users/{id}/revokeSignInSessions).
    .DESCRIPTION
        WRITE OPERATION. Invalidates refresh tokens and browser session cookies issued to the user
        by resetting signInSessionsValidFromDateTime. Access tokens that are already issued stay valid
        until they expire, unless the resource supports Continuous Access Evaluation (CAE).

        Revocation is a containment step, not remediation. If the attacker still holds a working
        credential or a registered method, they can sign in again. Order matters in an incident:
        remove attacker-registered methods and reset credentials first, then revoke.

        Requires: User.RevokeSessions.All (or Directory.ReadWrite.All) plus a role such as
        User Administrator, Helpdesk Administrator (non-admin targets) or Privileged Authentication Administrator.
    .EXAMPLE
        'victim@contoso.com' | Revoke-EptUserSession -Reason 'INC0012345 AiTM phishing' -WhatIf
    .EXAMPLE
        Get-EptAuthMethodAuditEvent -Days 1 -Activity '*registered*' |
            Where-Object { -not $_.SelfService } |
            Select-Object @{ n = 'UserPrincipalName'; e = { $_.TargetUpn } } -Unique |
            Revoke-EptUserSession -Reason 'Unexpected admin-registered method'
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Ept.SessionRevocation')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id', 'UserId', 'UPN')]
        [string] $UserPrincipalName,

        [Parameter(Mandatory)]
        [string] $Reason
    )
    begin { Assert-EptGraphConnection -RequiredScope 'User.RevokeSessions.All' }
    process {
        if (-not $PSCmdlet.ShouldProcess($UserPrincipalName, "Revoke all sign-in sessions ($Reason)")) { return }
        $uri = "https://graph.microsoft.com/v1.0/users/$(Resolve-EptUserId $UserPrincipalName)/revokeSignInSessions"
        $ok = $false
        try {
            # The API returns { "value": true }; the helper unwraps 'value' for us.
            $ok = [bool](Invoke-EptGraphRequest -Method POST -Uri $uri)
        }
        catch { Write-Error "Revocation failed for ${UserPrincipalName}: $($_.Exception.Message)" }
        [pscustomobject]@{
            PSTypeName        = 'Ept.SessionRevocation'
            UserPrincipalName = $UserPrincipalName
            Revoked           = $ok
            Reason            = $Reason
            Time              = (Get-Date).ToUniversalTime()
        }
    }
}

Source: EntraPasskeyToolkit/Public/Session.ps1

An incident response pipeline

Suppose Defender flags an infostealer on a laptop used by jdoe@contoso.com. Following the response order above:

$user = 'jdoe@contoso.com'
$case = 'INC0012399'

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes `
    'AuditLog.Read.All', 'Policy.Read.All', 'UserAuthenticationMethod.ReadWrite.All', 'User.RevokeSessions.All'

# 1. Evidence: what changed on the account recently, and from where?
$audit   = $user | Get-EptAuthMethodAuditEvent -Days 14
$signIns = $user | Get-EptSignInMethod -Days 7
$audit | Format-Table Time, Activity, Initiator, InitiatorIp, Details -Wrap
$signIns | Group-Object IPAddress, Country -NoElement | Sort-Object Count -Descending

# 2. Remove any passkey registered during the suspected compromise window (review first!)
$windowStart = (Get-Date).AddDays(-3)
Get-EptUserPasskey -UserPrincipalName $user |
    Where-Object Created -gt $windowStart |
    Remove-EptUserPasskey -WhatIf      # re-run without -WhatIf once confirmed

# 3. Reset the password through your normal process (not shown), then revoke sessions
$user | Revoke-EptUserSession -Reason "$case infostealer on endpoint" |
    Export-Csv "./$case-revocation.csv" -NoTypeInformation

# 4. Verify: new sign-ins should be phishing-resistant, from the reimaged or known device
$user | Get-EptSignInMethod -Days 1 | Format-Table Time, MethodClass, IPAddress, DeviceCompliant, TokenProtection

Best practices

  1. Pair passkeys with device-based Conditional Access for sensitive apps. That's the control that makes stolen tokens hard to replay.
  2. Pilot token protection on Windows for Exchange, SharePoint and Teams. Use the sign-in log status codes to find unsupported clients before enforcing.
  3. Rely on CAE-capable clients and resources, and shorten sign-in frequency on unmanaged devices.
  4. Measure used-method percentages, not only registrations, and alert when a privileged account's method class drops.
  5. Respond in this order: contain, remove persistence, reset, revoke, verify.
  6. Keep EDR in scope. Passkeys push attackers toward the endpoint, and the endpoint team needs to know that.

Next, Part 6 covers the accounts where all of this matters most: admins, break-glass accounts, and the "hidden admins" who never appear in a role report.

References

Passkey enrollment and account recovery, the new weak point

Registration campaigns, Temporary Access Pass, protecting security-info registration, and a help-desk recovery process that doesn't become a bypass

MFA to Passkeys in Microsoft Entra ID series · Part 4 of 7

Passkeys make the sign-in ceremony very hard to phish, so attackers go after the process that issues new credentials instead.

The most damaging identity intrusions of the last few years didn't break cryptography. The attackers called the service desk, impersonated an employee, sometimes with a spoofed number or an AI-generated voice, and asked for an MFA reset or a new device registration. Once the help desk registered a method the attacker controlled, every phishing-resistant control downstream worked as designed and protected the attacker's access.

So for passkeys, the security of the account equals the security of the weakest way to get a new credential onto it. This part covers four such ways, and how to close each:

  1. Self-service registration (Security info), protected by Conditional Access. This includes the BYOD trap where your own policies block registration.
  2. The first credential for new hires and users with no strong method, issued as a Temporary Access Pass.
  3. Registration campaigns that nudge users at sign-in.
  4. Recovery when someone loses every device.

Why enrollment is a high-risk action

Registering a passkey creates a long-lived credential. If an attacker who has a password plus a phishable second factor (or a stolen session) can add their own passkey, they have turned a temporary foothold into durable, phishing-resistant access. That access survives password resets.

Campaign season makes this worse. When the whole company is getting emails about "setting up your passkey", a phishing email about setting up your passkey looks perfectly normal.

1. Protect Register security information with Conditional Access

Conditional Access can target the user action Register security information (urn:user:registersecurityinfo). Microsoft's recommended policy requires strong conditions for any security-info change. A common design:

  • Users. All users. Exclude guests (TAP doesn't work for guests) and break-glass accounts.
  • Target. User actions → Register security information.
  • Grant. Require MFA (or an authentication strength that includes TAP), or a compliant or hybrid-joined device, depending on your design. Many organizations also add a trusted-location condition for first-time registration.

Details that matter:

  • TAP satisfies MFA requirements in Conditional Access. That's how a new hire with nothing registered gets through a policy that demands MFA to register.
  • Since 6 July 2026, policies targeting Register security information also apply during Windows Hello for Business and macOS Platform SSO credential registration. If WHfB provisioning started failing on managed devices in July, check this policy first.
  • Report-only is not supported for user-action policies. Test with a pilot group instead.
  • Watch for self-inflicted loops. If the registration policy demands phishing-resistant MFA, a user without a passkey can never register one. Use an authentication strength that includes TAP, or the MFA grant.

1a. When your own Conditional Access blocks registration (the BYOD trap)

This failure mode catches teams who have done everything else right. You protect mobile access with Require app protection policy (or Require approved client app) targeting All resources, which is standard BYOD hygiene. Then users on personal phones try to register a passkey in Microsoft Authenticator, and it fails with an error that points nowhere useful.

The cause is that Microsoft Authenticator doesn't support app protection policies on iOS or Android. Microsoft documents the symptom on the Authenticator passkey page, along with several workarounds:

  1. Use application filters to move the policy from All resources to specific applications.
  2. Use Require device to be marked as compliant instead, if MDM fully manages the device. This isn't an option for true BYOD.
  3. Grant a temporary, time-boxed exemption, with compensating controls such as a trusted-location or compliant-device requirement.
  4. Split the policy by device platform and allow a TAP or passkey on mobile, so no loop occurs.

Why it breaks even when your registration policy looks correct

Registration doesn't happen entirely inside the Register security information user action. The flow also authenticates against other resources, so a policy scoped to All resources still applies, even though the user satisfied the policy you wrote for security-info registration.

Read your own sign-in logs and you'll see it. Filter to the affected user around the failed attempt, and look at the Resource field on each entry rather than only the application. The registration flow involves resources such as:

Resource App ID
Azure Credential Configuration Endpoint Service ea890292-c8c8-4433-b5ea-b09d0668e1a6
Windows Azure Active Directory 00000002-0000-0000-c000-000000000000
Microsoft Graph 00000003-0000-0000-c000-000000000000

The Azure Credential Configuration Endpoint Service handles registration of security information gathered by apps such as Microsoft Authenticator and the My Sign-ins portal. When the app-protection policy blocks that call, registration fails.

Get-EptRegistrationBlock does this triage for you. It reads the beta sign-in logs, keeps only sign-ins against credential-registration resources, and names the Conditional Access policy that produced the failure:

function Get-EptRegistrationBlock {
    <#
    .SYNOPSIS
        Finds sign-ins where Conditional Access blocked or challenged a credential-registration resource.
    .DESCRIPTION
        Passkey registration does not happen entirely inside the "Register security information" user
        action. The flow also calls resources such as the Azure Credential Configuration Endpoint
        Service. A policy targeting All resources (for example "Require app protection policy" or
        "Require approved client app" on mobile) therefore blocks registration, usually with a
        confusing error in Microsoft Authenticator.

        This function reads the beta sign-in logs, keeps sign-ins against known registration resources
        (or any resource matched by -ResourceName), and reports which Conditional Access policies
        produced a failure or challenge, so you can see exactly which policy to adjust.

        Read-only. Requires AuditLog.Read.All plus Policy.Read.All to see appliedConditionalAccessPolicies,
        and a role such as Security Reader. Entra ID P1/P2.
    .PARAMETER ResourceName
        Additional resource display names to treat as registration-related (wildcards allowed).
    .EXAMPLE
        Get-EptRegistrationBlock -Days 7 | Format-Table Time, UserPrincipalName, Resource, Result, BlockingPolicies -Wrap
    .EXAMPLE
        # Which policies are responsible, across the whole window?
        Get-EptRegistrationBlock -Days 30 | Select-Object -ExpandProperty BlockingPolicies | Group-Object -NoElement | Sort-Object Count -Descending
    #>
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = '-ResourceName and -IncludeSuccess are read inside the nested query helpers.')]
    [CmdletBinding()]
    [OutputType('Ept.RegistrationBlock')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('UPN')]
        [string[]] $UserPrincipalName,

        [ValidateRange(1, 30)]
        [int] $Days = 7,

        [string[]] $ResourceName,

        # Include sign-ins that succeeded, to see the whole registration flow rather than only failures.
        [switch] $IncludeSuccess
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'AuditLog.Read.All', 'Policy.Read.All'
        $since = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
        $known = $script:EptRegistrationResource

        function Test-EptRegistrationResource($signIn) {
            if ($known.ContainsKey([string]$signIn.resourceId)) { return $true }
            foreach ($pattern in $ResourceName) {
                if ($signIn.resourceDisplayName -like $pattern) { return $true }
            }
            # The security-info user action itself, when the sign-in carries it.
            [string]$signIn.resourceDisplayName -like '*Credential Configuration*'
        }

        function Invoke-EptSignInQuery([string] $filter) {
            $uri = 'https://graph.microsoft.com/beta/auditLogs/signIns?$filter=' + [uri]::EscapeDataString($filter)
            Invoke-EptGraphRequest -Uri $uri -Headers @{ Prefer = 'include-unknown-enum-members' } |
                Where-Object { Test-EptRegistrationResource $_ } |
                ForEach-Object {
                    $failed = ($_.status.errorCode -ne 0)
                    if (-not $failed -and -not $IncludeSuccess) { return }
                    [pscustomobject]@{
                        PSTypeName        = 'Ept.RegistrationBlock'
                        Time              = [datetime]$_.createdDateTime
                        UserPrincipalName = $_.userPrincipalName
                        Resource          = if ($known.ContainsKey([string]$_.resourceId)) { $known[[string]$_.resourceId] } else { $_.resourceDisplayName }
                        ResourceId        = $_.resourceId
                        ClientApp         = $_.appDisplayName
                        DevicePlatform    = $_.deviceDetail.operatingSystem
                        Result            = if ($failed) { 'Failure' } else { 'Success' }
                        ErrorCode         = $_.status.errorCode
                        FailureReason     = $_.status.failureReason
                        ConditionalAccess = $_.conditionalAccessStatus
                        BlockingPolicies  = @($_.appliedConditionalAccessPolicies | Where-Object result -eq 'failure' | ForEach-Object displayName)
                        CorrelationId     = $_.correlationId
                    }
                }
        }
        $anyUser = $false
    }
    process {
        foreach ($u in $UserPrincipalName) {
            if (-not $u) { continue }
            $anyUser = $true
            Invoke-EptSignInQuery ("createdDateTime ge $since and userPrincipalName eq '{0}'" -f ($u -replace "'", "''"))
        }
    }
    end {
        if (-not $anyUser) { Invoke-EptSignInQuery "createdDateTime ge $since" }
    }
}

Source: EntraPasskeyToolkit/Public/Lifecycle.ps1

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes 'AuditLog.Read.All', 'Policy.Read.All'

# Which policy is blocking registration, and for whom?
Get-EptRegistrationBlock -Days 7 |
    Format-Table Time, UserPrincipalName, Resource, ClientApp, DevicePlatform, @{ n = 'Blocked by'; e = { $_.BlockingPolicies -join ', ' } } -Wrap

# Rank the offending policies across the window
Get-EptRegistrationBlock -Days 30 |
    Select-Object -ExpandProperty BlockingPolicies |
    Group-Object -NoElement | Sort-Object Count -Descending

# Follow one user's whole registration attempt, successes included
'byoduser@contoso.com' | Get-EptRegistrationBlock -Days 1 -IncludeSuccess | Sort-Object Time

The targeted fix, and its trade-off

Once you know the resource, the narrow remedy is to exclude the Azure Credential Configuration Endpoint Service from the app-protection policy and nothing else. In particular, do not exclude Microsoft Graph or Microsoft Authenticator, which would open a much larger gap. Some tenants also need Windows Azure Active Directory excluded, depending on where the user starts registration.

Warning: This exclusion is not in Microsoft's documented workaround list. It is well established among Entra practitioners (see the references below) and it is the narrowest fix available, but it is community guidance. Test it in a non-production tenant, and treat it as a deliberate, recorded risk decision rather than a default setting.

What the exclusion changes. Registration traffic to that one endpoint no longer requires a managed app. Everything else still applies: your policy targeting Register security information, your authentication-strength requirements, sign-in risk, locations, and app protection on every other resource. The residual risk is that someone who already holds a valid session on an unmanaged device could register a credential through a path you would otherwise have blocked.

Compensating controls, in order of preference:

  1. Scope the exclusion to the users who need it. Put users who haven't registered yet into a group, target that group with a copy of the policy that carries the exclusion, and exclude the group from your standard policy. Remove users from the group once they've registered. You can drive that from the audit log with Get-EptAuthMethodAuditEvent -Activity '*registered security info*'.
  2. Require MFA on the excluded resource with a separate policy, so the endpoint still demands a second factor. A push or OTP requirement is acceptable here, since the user is in the middle of upgrading to a passkey.
  3. Time-box the exclusion. Run it during the registration wave, then remove it and re-test.
  4. Prefer the platform split (workaround 4 above) if your population can register from a desktop instead, which avoids the exclusion entirely.

Then re-test registration on a real BYOD phone, and confirm with Get-EptRegistrationBlock that the failures stopped.

2. The first credential: Temporary Access Pass (TAP)

A TAP is a time-limited passcode, issued by an administrator, that lets a user sign in and register passwordless methods. It's the right bootstrap for new hires, for users coming off SMS, and for recovery.

Configure the TAP policy

Go to Entra ID → Authentication methods → Policies → Temporary Access Pass. You need Authentication Policy Administrator.

Temporary Access Pass policy settings

Setting Default Range Recommendation
Minimum lifetime 1 hour 10 min to 30 days 10 to 15 minutes for recovery
Maximum lifetime 8 hours 10 min to 30 days Keep it short. Use days only for pre-staged new-hire kits, with care
Default lifetime 1 hour 10 min to 30 days 1 hour
One-time use No Yes/No Yes (applies to every TAP in the tenant)
Length 8 8 to 48 characters 8 to 12 for usability

Behaviors to design around (Microsoft Learn):

  • One TAP per user. Creating a new one replaces the old one.
  • The 10-minute window. With a one-time TAP, the user must finish registering the passwordless method within 10 minutes of sign-in.
  • Session lifetime. Sessions are capped at the TAP's expiry, but TAP expiry doesn't retroactively revoke sessions already established. Pair TAP with a sign-in frequency control.
  • Who can issue. Authentication Administrators can create TAPs for non-admin members. Privileged Authentication Administrators can create them for admins too. Neither can create one for themselves.
  • Unsupported scenarios. TAP doesn't work for external guests, with the NPS extension, or with the AD FS adapter.
  • TAP doesn't satisfy the Phishing-resistant MFA strength (Part 3). A user bootstrapping with TAP must be able to reach the registration experience without meeting that strength.

In the portal, go to the user → Authentication methods → + Add authentication method → Temporary Access Pass:

Add Temporary Access Pass dialog

Temporary Access Pass details shown once

3. Registration campaigns (and what changed on 1 September)

The registration campaign nudges users at sign-in to register a target method, now Passkey (FIDO2) or Microsoft Authenticator, one method at a time.

Registration campaign configured for passkeys

Passkey registration nudge shown to a user

State Who decides the method and snoozes Notes
Microsoft managed Microsoft Passkey campaign: 1-day snooze, unlimited snoozes. You choose include/exclude targets. Eligibility requires users to be in an eligible passkey profile
Enabled You Choose the method, snooze days (0 to 14), and whether snoozes are limited. Limited snoozes allow 3, and 0 days with limited snoozes forces registration after the third
Disabled n/a No nudges

On 1 September 2026, Microsoft switched tenants with users enabled for SMS/voice to Microsoft managed targeting passkeys (retirement guidance). Decide deliberately whether to keep that:

  • Keep Microsoft managed if you want gentle, unlimited nudges while you finish recovery design.
  • Switch to Enabled when you are ready to drive completion with limited snoozes, and your help desk is staffed for the wave.
  • Exclude Tier 0 admins and anyone who must use a device-bound credential you issue. Otherwise they may create a synced passkey that their profile then refuses.

Nudge evaluation is per OS and browser combination. A passkey that suppresses the prompt on Windows may not suppress it on a Mac. Linux users aren't nudged. Guests aren't nudged (guest passkeys aren't available yet). The campaign works in mobile browsers and native iOS apps, but not yet in native Android apps. The rollout of this campaign experience is expected to finish by the end of September 2026, so behavior can differ between tenants until then (registration campaign).

4. Recovery that doesn't become a bypass

Recovery workflow: verify identity, open a route, issue TAP, register and test, close the case

Your recovery process is where you make the real security decisions. Write it down before enforcement:

Identity verification by tier

Tier Minimum verification before issuing a TAP
General workforce Call-back to a number from HR records (not the number the caller gives), plus manager confirmation or verified ID through a video call. Consider an automated identity-verification service such as Microsoft Entra Verified ID with Face Check
Sensitive roles Manager and security approval, plus live video verification against a government ID
Tier 0 admins In person or with a second admin present, and a break-glass-style dual control. Never accept remote self-assertion

Help-desk rules worth printing and taping to the wall

  1. Treat urgency as a warning sign. "The CEO needs this in 5 minutes" is a standard social-engineering script.
  2. Never read a TAP aloud to an unverified caller, and never send it to a number or email the caller provides.
  3. Never register a method on behalf of a user. The user registers their own passkey after signing in with the TAP.
  4. Always record the case ID. The toolkit makes -CaseId mandatory for exactly this reason.
  5. Unexpected request for an admin account? Stop, and escalate to security.

Hands-on: PowerShell for enrollment and recovery

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome `
    -Scopes 'UserAuthenticationMethod.ReadWrite.All', 'AuditLog.Read.All'

Issue a TAP inside a case: New-EptTemporaryAccessPass

  • -CaseId is mandatory.
  • ConfirmImpact is High, so you are prompted unless you pass -Confirm:$false.
  • The pass comes back as a SecureString, so it doesn't end up in your console scroll-back, Start-Transcript files, or pipeline logs by accident.
function New-EptTemporaryAccessPass {
    <#
    .SYNOPSIS
        Issues a Temporary Access Pass (TAP) for a verified onboarding or recovery case.
    .DESCRIPTION
        WRITE OPERATION. Creates a TAP via POST /users/{id}/authentication/temporaryAccessPassMethods.
        Creating a TAP replaces any existing TAP for the user. The pass value is only returned at
        creation time; this function returns it as a SecureString so it is not echoed to the console,
        transcripts or pipeline logs by accident.

        Guard rails:
          * -CaseId is mandatory and is written to the verbose stream and the returned object,
            so every TAP can be traced to a ticket.
          * SupportsShouldProcess with ConfirmImpact High: you are prompted unless you pass -Confirm:$false.
          * Lifetime and one-time use must also be allowed by the tenant's TAP policy, or Graph rejects the request.

        Requires: UserAuthenticationMethod.ReadWrite.All plus Authentication Administrator
        (Privileged Authentication Administrator for admin accounts).
    .EXAMPLE
        $tap = New-EptTemporaryAccessPass -UserPrincipalName jdoe@contoso.com -CaseId INC0012345 -LifetimeInMinutes 60 -OneTime
        $tap.Pass | ConvertFrom-SecureString -AsPlainText   # reveal once, deliver via the approved channel
    .EXAMPLE
        # Bulk onboarding: CSV columns UserPrincipalName, CaseId, LifetimeInMinutes
        Import-Csv ./new-hires.csv | New-EptTemporaryAccessPass -OneTime -Confirm:$false
    #>
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '',
        Justification = 'Graph returns the TAP in clear text once; it is wrapped in a SecureString immediately.')]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Ept.TemporaryAccessPass')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id', 'UserId', 'UPN')]
        [string] $UserPrincipalName,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidatePattern('^\S{3,}$')]
        [string] $CaseId,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(10, 43200)]
        [int] $LifetimeInMinutes = 60,

        [switch] $OneTime,

        [datetime] $StartDateTime
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'UserAuthenticationMethod.ReadWrite.All'
    }
    process {
        $body = @{
            lifetimeInMinutes = $LifetimeInMinutes
            isUsableOnce      = [bool]$OneTime
        }
        if ($StartDateTime) { $body.startDateTime = $StartDateTime.ToUniversalTime().ToString('o') }

        $target = "$UserPrincipalName (case $CaseId, $LifetimeInMinutes min, one-time=$([bool]$OneTime))"
        if (-not $PSCmdlet.ShouldProcess($target, 'Create Temporary Access Pass (replaces any existing TAP)')) { return }

        Write-Verbose "Issuing TAP for $UserPrincipalName under case $CaseId"
        $uri = "https://graph.microsoft.com/v1.0/users/$(Resolve-EptUserId $UserPrincipalName)/authentication/temporaryAccessPassMethods"
        $result = Invoke-EptGraphRequest -Method POST -Uri $uri -Body $body

        $secure = ConvertTo-SecureString -String $result.temporaryAccessPass -AsPlainText -Force
        $result.temporaryAccessPass = $null

        [pscustomobject]@{
            PSTypeName        = 'Ept.TemporaryAccessPass'
            UserPrincipalName = $UserPrincipalName
            CaseId            = $CaseId
            MethodId          = $result.id
            StartDateTime     = $result.startDateTime
            LifetimeInMinutes = $result.lifetimeInMinutes
            IsUsableOnce      = $result.isUsableOnce
            Pass              = $secure
        }
    }
}

Source: EntraPasskeyToolkit/Public/Lifecycle.ps1

# Recovery: one-time, 15 minutes, after identity verification is recorded in INC0012345
$tap = New-EptTemporaryAccessPass -UserPrincipalName 'jdoe@contoso.com' -CaseId 'INC0012345' -LifetimeInMinutes 15 -OneTime

# Reveal once, deliver via the approved channel, then discard
$tap.Pass | ConvertFrom-SecureString -AsPlainText
Remove-Variable tap

For bulk onboarding of new hires, UserPrincipalName, CaseId and LifetimeInMinutes all bind by property name, so a CSV pipes straight in. Handle the output with care, because it contains live credentials.

# new-hires.csv: UserPrincipalName,CaseId,LifetimeInMinutes
Import-Csv ./new-hires.csv |
    New-EptTemporaryAccessPass -OneTime -Confirm:$false |
    ForEach-Object {
        # Hand each pass to your secure delivery process, e.g. a sealed onboarding letter or the manager's secure portal.
        # Don't Export-Csv this output to a file share.
        [pscustomobject]@{ User = $_.UserPrincipalName; Case = $_.CaseId; Expires = ([datetime]$_.StartDateTime).AddMinutes($_.LifetimeInMinutes) }
    }

Remove-Variable is housekeeping, not guaranteed memory erasure. For high-assurance environments, deliver TAPs through a system designed for it rather than an interactive admin console.

Remove a lost or unauthorized passkey: Remove-EptUserPasskey

This takes pipeline input from Get-EptUserPasskey (Part 2), so you can filter first, preview with -WhatIf, then delete.

function Remove-EptUserPasskey {
    <#
    .SYNOPSIS
        Deletes a user's passkey (FIDO2) registration, for lost, stolen or unauthorized credentials.
    .DESCRIPTION
        WRITE OPERATION. DELETE /users/{id}/authentication/fido2Methods/{methodId}.
        Designed to take pipeline input from Get-EptUserPasskey so you can filter first and
        delete second, with -WhatIf to preview. Deleting a credential does not end sessions that
        were already issued: pair it with Revoke-EptUserSession during an incident.

        Requires: UserAuthenticationMethod.ReadWrite.All plus Authentication Administrator
        (Privileged Authentication Administrator for admin accounts).
    .EXAMPLE
        Get-EptUserPasskey -UserPrincipalName jdoe@contoso.com |
            Where-Object DisplayName -eq 'YubiKey lost 2026-09' |
            Remove-EptUserPasskey -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('UPN')]
        [string] $UserPrincipalName,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string] $MethodId,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string] $DisplayName
    )
    begin { Assert-EptGraphConnection -RequiredScope 'UserAuthenticationMethod.ReadWrite.All' }
    process {
        $label = if ($DisplayName) { "'$DisplayName' ($MethodId)" } else { $MethodId }
        if ($PSCmdlet.ShouldProcess($UserPrincipalName, "Delete passkey $label")) {
            $uri = "https://graph.microsoft.com/v1.0/users/$(Resolve-EptUserId $UserPrincipalName)/authentication/fido2Methods/$MethodId"
            Invoke-EptGraphRequest -Method DELETE -Uri $uri | Out-Null
        }
    }
}

Source: EntraPasskeyToolkit/Public/Lifecycle.ps1

# Remove the passkey the user reported lost (identify it by name and date with the user)
Get-EptUserPasskey -UserPrincipalName 'jdoe@contoso.com' |
    Where-Object { $_.DisplayName -eq 'YubiKey blue' -and $_.Created -lt '2026-06-01' } |
    Remove-EptUserPasskey -WhatIf

Watch the registration pipeline: Get-EptAuthMethodAuditEvent

Every registration, deletion and admin change to authentication methods is written to the Entra audit log under the Authentication Methods service. Verified activity names include:

  • User registered security info
  • Admin registered security info
  • User deleted security info
  • Admin deleted security info
  • User changed default security info
  • Get passkey creation options

(audit activity reference).

The function filters on the service rather than a fixed list of names, so it keeps working as Microsoft adds activities. It also flags whether the change was self-service, meaning the initiator is also the target.

function Get-EptAuthMethodAuditEvent {
    <#
    .SYNOPSIS
        Returns audit events for authentication-method changes (registrations, deletions, TAP issuance).
    .DESCRIPTION
        Queries GET /auditLogs/directoryAudits filtered to loggedByService 'Authentication Methods'
        within a time window, and flattens initiator and target for easy filtering and correlation.
        Filtering on the service instead of a hard-coded list of activity names keeps the function
        working when Microsoft adds or renames activities. Use -Activity to narrow client-side.

        Requires: AuditLog.Read.All plus a role such as Reports Reader or Security Reader.
        Retention depends on licence (7 days free, 30 days P1/P2); stream to Log Analytics or Sentinel
        for anything longer.
    .PARAMETER UserPrincipalName
        Limit results to events targeting these users. Accepts pipeline input.
    .PARAMETER Activity
        Wildcard filter applied to activityDisplayName, e.g. '*registered*' or '*Temporary*'.
    .EXAMPLE
        Get-EptAuthMethodAuditEvent -Days 7 -Activity '*registered security info*'
    .EXAMPLE
        Get-EptPrivilegedUser | Get-EptAuthMethodAuditEvent -Days 30 | Sort-Object Time
    #>
    [CmdletBinding()]
    [OutputType('Ept.AuthMethodAuditEvent')]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('UPN')]
        [string[]] $UserPrincipalName,

        [ValidateRange(1, 30)]
        [int] $Days = 7,

        [string] $Activity = '*'
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'AuditLog.Read.All'
        $since = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')
        $filter = "loggedByService eq 'Authentication Methods' and activityDateTime ge $since"
        $uri = 'https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?$filter=' + [uri]::EscapeDataString($filter)
        $targets = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    }
    process {
        foreach ($u in $UserPrincipalName) { if ($u) { [void]$targets.Add($u) } }
    }
    end {
        Invoke-EptGraphRequest -Uri $uri |
            Where-Object activityDisplayName -Like $Activity |
            ForEach-Object {
                $target = $_.targetResources | Select-Object -First 1
                $row = [pscustomobject]@{
                    PSTypeName    = 'Ept.AuthMethodAuditEvent'
                    Time          = [datetime]$_.activityDateTime
                    Activity      = $_.activityDisplayName
                    Result        = $_.result
                    ResultReason  = $_.resultReason
                    Initiator     = if ($_.initiatedBy.user) { $_.initiatedBy.user.userPrincipalName } else { $_.initiatedBy.app.displayName }
                    InitiatorIp   = $_.initiatedBy.user.ipAddress
                    TargetUpn     = $target.userPrincipalName
                    TargetId      = $target.id
                    SelfService   = ($_.initiatedBy.user.id -and $_.initiatedBy.user.id -eq $target.id)
                    Details       = ($_.additionalDetails | ForEach-Object { "$($_.key)=$($_.value)" }) -join '; '
                    CorrelationId = $_.correlationId
                }
                if ($targets.Count -eq 0 -or $targets.Contains([string]$row.TargetUpn) -or $targets.Contains([string]$row.TargetId)) { $row }
            }
    }
}

Source: EntraPasskeyToolkit/Public/Lifecycle.ps1

Three detection queries built on it:

# 1. Admin-initiated method changes in the last 7 days: every one should map to a ticket
Get-EptAuthMethodAuditEvent -Days 7 -Activity 'Admin*' |
    Format-Table Time, Activity, Initiator, TargetUpn, Details -Wrap

# 2. Any security-info change on a privileged account in the last 30 days
Get-EptPrivilegedUser | Get-EptAuthMethodAuditEvent -Days 30 | Sort-Object Time

# 3. Registrations from IP addresses that none of your recent sign-ins came from,
#    a cheap signal for an attacker registering from their infrastructure
$knownIps = Get-EptSignInMethod -Days 7 -SuccessOnly | Select-Object -ExpandProperty IPAddress -Unique
Get-EptAuthMethodAuditEvent -Days 7 -Activity '*registered*' |
    Where-Object { $_.SelfService -and $_.InitiatorIp -and $_.InitiatorIp -notin $knownIps }

Audit logs are kept for 7 days on Entra ID Free and 30 days on P1/P2. For alerting, stream AuditLogs to Log Analytics or Microsoft Sentinel and build the same logic in KQL.

Best practices

  1. Treat "register a new method" like "reset a password for an admin." Log every registration and review it.
  2. Deploy a Conditional Access policy on Register security information before you start a registration campaign. Then test registration on a real BYOD phone, because an All resources policy can still block it.
  3. Make one-time, short-lived TAPs the only bootstrap. Retire "temporary SMS enablement" as a recovery method.
  4. Require two credentials per user so most "lost device" events become self-service.
  5. Tier your identity verification. Admins get in-person or dual-control recovery.
  6. Reconcile tickets, TAPs and registrations. Every TAP maps to a ticket, and every admin-initiated registration in the audit log maps to a TAP.
  7. Tell users what legitimate looks like: "IT will never ask you to scan a QR code from an email or read us a code." Give them a reporting channel.

Next, Part 5: a perfectly registered, perfectly enforced passkey still issues session tokens. What protects those?

References

Enabling Passkeys Is Not the Same as Enforcing Them

Authentication strengths, Conditional Access design, and closing the weak-fallback and device-code gaps

MFA to Passkeys in Microsoft Entra ID series · Part 3 of 7

ok lets start with a scenario that plays out in real tenants. An administrator registers a shiny new FIDO2 security key. The security team marks the account as "phishing-resistant". Three months later the account is compromised through an AiTM phishing page. The attacker didn't touch the key. They sent a push notification, because push was still an acceptable way to satisfy every Conditional Access policy that applied to that account.

A stronger credential doesn't remove weaker paths. Entra ID has two separate policy engines, and you need both:

Engine Answers the question Where
Authentication methods policy Which methods may this user register and use at all? Entra ID → Authentication methods → Policies
Conditional Access + authentication strengths Which methods are required to reach this resource, under these conditions? Entra ID → Conditional Access

Part 2 configured the first engine. This part configures the second, and then proves it works.

Authentication strengths

An authentication strength is a named list of acceptable method combinations. A Conditional Access policy can demand a strength instead of the generic "Require multifactor authentication" grant. Entra ID has three built-in strengths, identical in every tenant and maintained by Microsoft as new methods ship:

Built-in strength ID Allowed combinations (summary)
Multifactor authentication 00000000-0000-0000-0000-000000000002 Everything below, plus password + SMS/voice/push/OATH, Temporary Access Pass, federated MFA…
Passwordless MFA 00000000-0000-0000-0000-000000000003 WHfB, FIDO2, multifactor CBA, Authenticator phone sign-in (deviceBasedPush)
Phishing-resistant MFA 00000000-0000-0000-0000-000000000004 windowsHelloForBusiness, fido2, x509CertificateMultiFactor

Built-in phishing-resistant MFA strength definition

Custom strengths (up to 15 per tenant) let you go further. The most valuable use is pinning passkeys to approved models. Under Passkeys (FIDO2) → Advanced options you can list the AAGUIDs a strength accepts:

Adding an AAGUID in authentication strength advanced options

A user who signs in with a passkey outside the list sees "You can't get there from here":

Sign-in error when a passkey is not allowed by the strength

Limitations to plan around

Each item below is from Microsoft Learn: authentication strengths:

  • You can't combine Require multifactor authentication and Require authentication strength in the same policy.
  • Strengths don't restrict the first factor. Conditional Access evaluates after initial authentication. A user can still type a password first, then be asked to step up with a passkey.
  • Temporary Access Pass satisfies only the built-in MFA strength, not Passwordless or Phishing-resistant. That matters for onboarding (Part 4).
  • External authentication methods are currently incompatible with authentication strengths.
  • Known issue with sign-in frequency: users "can satisfy both requirements at two different times". A passkey sign-in yesterday plus a Windows Hello unlock today can together satisfy a one-hour sign-in frequency.
  • Only built-in directory roles are honored in Conditional Access user targeting. Administrative-unit-scoped and custom roles are not. Target admin groups if you use custom roles.
  • Microsoft Authenticator passkey registration loop: a policy requiring phishing-resistant MFA on All resources can stop users from registering an Authenticator passkey. So can policies requiring an approved client app or app protection policy, because Authenticator doesn't support app protection policies on iOS or Android. This bites hardest on BYOD phones, and the failure is easy to misdiagnose because registration touches resources beyond the Register security information user action. Part 4 has the diagnosis (Get-EptRegistrationBlock) and the targeted fix.

A reference Conditional Access design

Name policies so they sort and read well in sign-in logs. A minimal phishing-resistant rollout needs these:

Policy Users Resources Grant / condition Start state
CA010 Block device code flow All users, exclude break-glass + documented exceptions All resources Authentication flows = device code flow → Block Report-only
CA011 Block authentication transfer All users, exclude break-glass All resources Authentication flows = authentication transfer → Block Report-only
CA100 Admins require phishing-resistant MFA 14 privileged directory roles, exclude break-glass All resources Authentication strength = Phishing-resistant MFA (or custom Tier 0 strength) Report-only → On
CA110 Pilot require phishing-resistant MFA Pilot group(s), exclude break-glass All resources Authentication strength = Phishing-resistant MFA Report-only → On
CA120 Secure security-info registration All users, exclude guests + break-glass User action: Register security information Trusted conditions or TAP/MFA (Part 4) On (report-only isn't supported for user actions)

Microsoft's deployment guide goes further for the enforcement phase and recommends one policy per OS group (Windows, macOS, iOS, Android, Other). You can then enforce platform by platform as each passes its pilot (deployment guide).

Build the policy in the portal

Entra ID → Conditional Access → Policies → New policy:

  1. Users: include the pilot group or the directory roles. Under Exclude, add the emergency-access group.
  2. Target resources: All resources.
  3. Grant: Grant access → Require authentication strength → Phishing-resistant MFA.
  4. Enable policy: Report-only.

Conditional Access grant control requiring an authentication strength

Microsoft also ships this as a template: Require phishing-resistant multifactor authentication for administrators (policy guide). Heed its warning: "Enabling this policy without completing this step [registering the appropriate methods] risks locking you out of your tenant."

Block device code flow

Passkeys can't stop device code phishing, because the victim signs in on the real Microsoft page. Conditional Access can. In the policy's Conditions → Authentication flows, select Device code flow and grant Block.

Conditional Access conditions including Authentication flows]

Policy blocking authentication transfer

Things to know before you enforce (concept, how-to):

  • Find your legitimate users first. Teams Rooms and other shared devices, some CLI tooling, and provisioning workflows use device code flow. For Teams devices, Microsoft recommends excluding the specific resource accounts, not broad groups (Teams devices guidance).
  • Protocol tracking follows the session. Once a session started with device code flow, later refreshes are still treated as device-code-originated and can be blocked with AADSTS530036. The sign-in log shows it under Original transfer method.
  • Device Registration Service. Authentication-flows policies targeting All resources also apply to device registration. If you register devices through device code flow, exclude the Device Registration Service app (01cb2876-7ebd-4aa4-9cc9-d28bd4d359a9).

Report-only first, always

Report-only evaluates the policy on every sign-in and records what would have happened, without blocking anyone. Review it in the sign-in log's Report-only tab or in the Policy impact view (preview, Security Reader or higher):

Report-only: Failure means the user would have been blocked. User action required means they would have been prompted, for example to use a passkey they don't have. Both are your pre-enforcement to-do list. Report-only isn't available for policies scoped to user actions such as Register security information.

Hands-on: build and verify enforcement with PowerShell

Connect-MgGraph -TenantId $tenantId -ContextScope Process -NoWelcome -Scopes `
    'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess', 'RoleManagement.Read.Directory', 'GroupMember.Read.All', 'AuditLog.Read.All'

Graph known issue: creating or patching Conditional Access policies requires consent to Policy.Read.All in addition to Policy.ReadWrite.ConditionalAccess.

Inspect strengths: Get-EptAuthenticationStrength

function Get-EptAuthenticationStrength {
    <#
    .SYNOPSIS
        Lists built-in and custom authentication strengths with their allowed method combinations.
    .DESCRIPTION
        GET /policies/authenticationStrengthPolicies (v1.0). Output exposes StrengthId, which binds by
        property name to New-EptStrengthPolicy -AuthenticationStrengthId.
        Requires: Policy.Read.All (or Policy.Read.AuthenticationMethod).
    .EXAMPLE
        Get-EptAuthenticationStrength | Format-Table Name, PolicyType, AllowsPhishable
    .EXAMPLE
        Get-EptAuthenticationStrength -Name 'Admin*' | New-EptStrengthPolicy -DisplayName 'CA101 Admins - custom strength' -AdminRoles -ExcludeGroupId $bg
    #>
    [CmdletBinding()]
    [OutputType('Ept.AuthenticationStrength')]
    param(
        [SupportsWildcards()]
        [string] $Name = '*'
    )
    Assert-EptGraphConnection -RequiredScope 'Policy.Read.All'
    $phishable = 'sms|voice|softwareOath|hardwareOath|microsoftAuthenticatorPush|deviceBasedPush|temporaryAccessPass|email|federated|qrCodePin|password'

    Invoke-EptGraphRequest -Uri 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies' |
        Where-Object displayName -Like $Name |
        ForEach-Object {
            $aaguids = @($_.combinationConfigurations | Where-Object allowedAAGUIDs | ForEach-Object allowedAAGUIDs)
            [pscustomobject]@{
                PSTypeName          = 'Ept.AuthenticationStrength'
                StrengthId          = $_.id
                Name                = $_.displayName
                PolicyType          = $_.policyType
                AllowedCombinations = @($_.allowedCombinations)
                AllowsPhishable     = [bool](@($_.allowedCombinations) -match $phishable)
                RestrictedAaguids   = $aaguids
            }
        }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

AllowsPhishable is the flag that matters. Any strength that accepts sms, voice, OATH, push, TAP, or a password combination is not phishing-resistant, whatever its name says.

Get-EptAuthenticationStrength | Format-Table Name, PolicyType, AllowsPhishable, @{ n = 'Combinations'; e = { $_.AllowedCombinations -join ', ' } } -Wrap

Create a Tier 0 strength: New-EptAuthenticationStrength

function New-EptAuthenticationStrength {
    <#
    .SYNOPSIS
        Creates a custom authentication strength, optionally pinning passkeys to approved AAGUIDs.
    .DESCRIPTION
        WRITE OPERATION. POST /policies/authenticationStrengthPolicies (v1.0).
        The typical use is a "privileged" strength that only accepts FIDO2 keys from an approved
        model list, plus Windows Hello for Business and multifactor certificates.
        Tenants can hold up to 15 custom strengths.
        AAGUID restriction is only a hard control when attestation is enforced on the matching
        passkey profile; otherwise an authenticator can claim any AAGUID.
        Requires: Policy.ReadWrite.ConditionalAccess + Conditional Access Administrator or Security Administrator.
    .EXAMPLE
        New-EptAuthenticationStrength -DisplayName 'Admins - approved security keys' `
            -AllowedCombination fido2, windowsHelloForBusiness -AllowedAaguid $approvedKeys -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType('Ept.AuthenticationStrength')]
    param(
        [Parameter(Mandatory)]
        [string] $DisplayName,

        [string] $Description = 'Created by EntraPasskeyToolkit',

        [ValidateSet('fido2', 'windowsHelloForBusiness', 'x509CertificateMultiFactor', 'deviceBasedPush',
                     'temporaryAccessPassOneTime', 'temporaryAccessPassMultiUse')]
        [string[]] $AllowedCombination = @('fido2', 'windowsHelloForBusiness', 'x509CertificateMultiFactor'),

        [ValidatePattern('^[0-9a-fA-F-]{36}$')]
        [string[]] $AllowedAaguid
    )
    Assert-EptGraphConnection -RequiredScope 'Policy.ReadWrite.ConditionalAccess'

    $body = [ordered]@{
        displayName           = $DisplayName
        description           = $Description
        requirementsSatisfied = 'mfa'
        allowedCombinations   = @($AllowedCombination)
    }
    if ($AllowedAaguid) {
        if ($AllowedCombination -notcontains 'fido2') { throw '-AllowedAaguid requires fido2 in -AllowedCombination.' }
        $body.combinationConfigurations = @(@{
            '@odata.type'         = '#microsoft.graph.fido2CombinationConfiguration'
            appliesToCombinations = @('fido2')
            allowedAAGUIDs        = @($AllowedAaguid | ForEach-Object ToLower)
        })
    }

    if ($PSCmdlet.ShouldProcess($DisplayName, "Create authentication strength ($($AllowedCombination -join ', '))")) {
        $created = Invoke-EptGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/policies/authenticationStrengthPolicies' -Body $body
        Get-EptAuthenticationStrength | Where-Object StrengthId -eq $created.id
    }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

$approvedKeyAaguids = @('ee882879-721c-4913-9775-3dfcce97072a', 'fa2b99dc-9e39-4257-8f92-4a30d23c4118')  # replace with yours

New-EptAuthenticationStrength -DisplayName 'Tier0 - approved security keys + WHfB' `
    -AllowedCombination fido2, windowsHelloForBusiness -AllowedAaguid $approvedKeyAaguids -WhatIf

Review existing policies: Get-EptConditionalAccessPolicy

This flattens every policy into a row with the flags you care about: RequiresPhishResistant, RequiresMfa, BlocksDeviceCode, targets and exclusions.

function Get-EptConditionalAccessPolicy {
    <#
    .SYNOPSIS
        Flattens Conditional Access policies into reviewable rows with MFA-relevant flags.
    .DESCRIPTION
        GET /identity/conditionalAccess/policies (v1.0). Resolves the authentication strength name and
        flags policies that require phishing-resistant MFA, generic MFA, or block device code flow.
        It does NOT compute effective access; group nesting, exclusions and multiple policies must still
        be evaluated with the What If tool or report-only results.
        Requires: Policy.Read.All + a role such as Security Reader or Global Reader.
    .EXAMPLE
        Get-EptConditionalAccessPolicy | Where-Object RequiresPhishResistant | Format-Table Name, State, IncludeUsers, IncludeRoles
    .EXAMPLE
        Get-EptConditionalAccessPolicy | Where-Object { $_.State -eq 'enabled' -and $_.RequiresMfa -and -not $_.StrengthName }
    #>
    [CmdletBinding()]
    [OutputType('Ept.ConditionalAccessPolicy')]
    param(
        [ValidateSet('enabled', 'disabled', 'enabledForReportingButNotEnforced')]
        [string[]] $State
    )
    Assert-EptGraphConnection -RequiredScope 'Policy.Read.All'

    $strengths = @{}
    Get-EptAuthenticationStrength | ForEach-Object { $strengths[$_.StrengthId] = $_ }

    Invoke-EptGraphRequest -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' |
        Where-Object { -not $State -or $_.state -in $State } |
        ForEach-Object {
            $c = $_.conditions
            $g = $_.grantControls
            $strengthId = $g.authenticationStrength.id
            $strength = if ($strengthId) { $strengths[$strengthId] }
            [pscustomobject]@{
                PSTypeName             = 'Ept.ConditionalAccessPolicy'
                PolicyId               = $_.id
                Name                   = $_.displayName
                State                  = $_.state
                IncludeUsers           = @($c.users.includeUsers)
                IncludeGroups          = @($c.users.includeGroups)
                IncludeRoles           = @($c.users.includeRoles)
                ExcludeUsers           = @($c.users.excludeUsers)
                ExcludeGroups          = @($c.users.excludeGroups)
                Applications           = @($c.applications.includeApplications)
                UserActions            = @($c.applications.includeUserActions)
                ClientAppTypes         = @($c.clientAppTypes)
                TransferMethods        = $c.authenticationFlows.transferMethods
                GrantOperator          = $g.operator
                BuiltInControls        = @($g.builtInControls)
                StrengthName           = $strength.Name
                RequiresMfa            = (@($g.builtInControls) -contains 'mfa') -or [bool]$strengthId
                RequiresPhishResistant = [bool]($strength -and -not $strength.AllowsPhishable)
                BlocksDeviceCode       = (@($g.builtInControls) -contains 'block') -and ([string]$c.authenticationFlows.transferMethods -match 'deviceCodeFlow')
                SessionControls        = @($_.sessionControls.PSObject.Properties | Where-Object { $_.Value } | ForEach-Object Name)
            }
        }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

# Policies that ask for "MFA" but accept phishable methods: your weak-fallback suspects
Get-EptConditionalAccessPolicy -State enabled |
    Where-Object { $_.RequiresMfa -and -not $_.RequiresPhishResistant } |
    Format-Table Name, StrengthName, @{ n = 'Users'; e = { $_.IncludeUsers + $_.IncludeGroups + $_.IncludeRoles -join ', ' } }

# Is device code flow blocked anywhere?
Get-EptConditionalAccessPolicy | Where-Object BlocksDeviceCode | Format-Table Name, State

# Snapshot before every change (JSON is review evidence, not a restore script)
Get-EptConditionalAccessPolicy | ConvertTo-Json -Depth 5 | Set-Content "./ca-snapshot-$(Get-Date -Format yyyyMMdd-HHmm).json"

Create the policies: New-EptStrengthPolicy and New-EptDeviceCodeBlockPolicy

Both default to report-only and both make -ExcludeGroupId mandatory, so nobody can forget the break-glass accounts. New-EptStrengthPolicy takes StrengthId from the pipeline, so the output of Get-EptAuthenticationStrength flows straight in. -AdminRoles resolves Microsoft's list of 14 privileged roles by display name, which avoids hard-coded role GUIDs.

function New-EptStrengthPolicy {
    <#
    .SYNOPSIS
        Creates a Conditional Access policy that requires an authentication strength (report-only by default).
    .DESCRIPTION
        WRITE OPERATION. POST /identity/conditionalAccess/policies (v1.0).
        Defaults follow Microsoft's deployment guidance: built-in Phishing-resistant MFA strength,
        All resources, report-only state. -ExcludeGroupId is mandatory so that break-glass
        accounts are never forgotten.

        Accepts StrengthId from Get-EptAuthenticationStrength over the pipeline.
        Only built-in directory roles are honoured by Conditional Access; administrative-unit-scoped
        and custom roles are not.

        Requires: Policy.Read.All + Policy.ReadWrite.ConditionalAccess (+ RoleManagement.Read.Directory
        for -AdminRoles) and Conditional Access Administrator.
    .EXAMPLE
        New-EptStrengthPolicy -DisplayName 'CA100 Admins - Phishing-resistant MFA' -AdminRoles -ExcludeGroupId $breakGlassGroupId -WhatIf
    .EXAMPLE
        New-EptStrengthPolicy -DisplayName 'CA110 Pilot - Phishing-resistant MFA' -IncludeGroupId $pilotGroupId -ExcludeGroupId $breakGlassGroupId
    #>
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '',
        Justification = '-AdminRoles and -AllUsers select parameter sets.')]
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'Groups')]
    [OutputType('Ept.ConditionalAccessPolicy')]
    param(
        [Parameter(Mandatory)]
        [string] $DisplayName,

        [Parameter(Mandatory, ParameterSetName = 'Groups')]
        [guid[]] $IncludeGroupId,

        [Parameter(Mandatory, ParameterSetName = 'Roles')]
        [switch] $AdminRoles,

        [Parameter(ParameterSetName = 'Roles')]
        [string[]] $RoleName = $script:EptAdminRoleName,

        [Parameter(Mandatory, ParameterSetName = 'AllUsers')]
        [switch] $AllUsers,

        [Parameter(Mandatory)]
        [guid[]] $ExcludeGroupId,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('StrengthId')]
        [guid] $AuthenticationStrengthId = $script:EptBuiltInStrength.PhishingResistant,

        [string[]] $IncludeApplication = @('All'),

        [ValidateSet('enabled', 'disabled', 'enabledForReportingButNotEnforced')]
        [string] $State = 'enabledForReportingButNotEnforced'
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess'
    }
    process {
        $users = @{ excludeGroups = @($ExcludeGroupId.Guid) }
        switch ($PSCmdlet.ParameterSetName) {
            'Groups' { $users.includeGroups = @($IncludeGroupId.Guid) }
            'AllUsers' { $users.includeUsers = @('All') }
            'Roles' {
                Assert-EptGraphConnection -RequiredScope 'RoleManagement.Read.Directory'
                $users.includeRoles = @(foreach ($r in $RoleName) {
                    $def = Invoke-EptGraphRequest -Uri ("https://graph.microsoft.com/v1.0/roleManagement/directory/roleDefinitions?`$filter=displayName eq '{0}'&`$select=templateId" -f $r)
                    if (-not $def) { throw "Role '$r' not found." }
                    $def.templateId
                })
            }
        }

        $body = [ordered]@{
            displayName   = $DisplayName
            state         = $State
            conditions    = @{
                users          = $users
                applications   = @{ includeApplications = $IncludeApplication }
                clientAppTypes = @('all')
            }
            grantControls = @{
                operator               = 'OR'
                authenticationStrength = @{ id = $AuthenticationStrengthId.Guid }
            }
        }

        if ($State -eq 'enabled') {
            Write-Warning 'Creating an ENFORCED policy. Confirm every targeted user has a qualifying method registered, and that break-glass accounts are excluded.'
        }
        if ($PSCmdlet.ShouldProcess($DisplayName, "Create CA policy [$State] requiring strength $AuthenticationStrengthId")) {
            $created = Invoke-EptGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' -Body $body
            Get-EptConditionalAccessPolicy | Where-Object PolicyId -eq $created.id
        }
    }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

function New-EptDeviceCodeBlockPolicy {
    <#
    .SYNOPSIS
        Creates a Conditional Access policy that blocks device code flow (and optionally authentication transfer).
    .DESCRIPTION
        WRITE OPERATION. Microsoft recommends blocking device code flow wherever possible, because an
        attacker can get a victim to approve a code on the genuine Microsoft sign-in page.
        Created in report-only state by default so you can find legitimate users first
        (Teams Rooms, shared-device provisioning, some CLI workflows).
        -ExcludeDeviceRegistrationService excludes the Device Registration Service app
        (01cb2876-7ebd-4aa4-9cc9-d28bd4d359a9) for tenants that register devices with device code flow.
        Requires: Policy.Read.All + Policy.ReadWrite.ConditionalAccess + Conditional Access Administrator.
    .EXAMPLE
        New-EptDeviceCodeBlockPolicy -DisplayName 'CA010 All users - Block device code flow' -ExcludeGroupId $breakGlass, $teamsRoomsAccounts -WhatIf
    #>
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType('Ept.ConditionalAccessPolicy')]
    param(
        [Parameter(Mandatory)]
        [string] $DisplayName,

        [Parameter(Mandatory)]
        [guid[]] $ExcludeGroupId,

        [switch] $IncludeAuthenticationTransfer,

        [switch] $ExcludeDeviceRegistrationService,

        [ValidateSet('enabled', 'disabled', 'enabledForReportingButNotEnforced')]
        [string] $State = 'enabledForReportingButNotEnforced'
    )
    Assert-EptGraphConnection -RequiredScope 'Policy.Read.All', 'Policy.ReadWrite.ConditionalAccess'

    $transfer = if ($IncludeAuthenticationTransfer) { 'deviceCodeFlow,authenticationTransfer' } else { 'deviceCodeFlow' }
    $apps = @{ includeApplications = @('All') }
    if ($ExcludeDeviceRegistrationService) { $apps.excludeApplications = @('01cb2876-7ebd-4aa4-9cc9-d28bd4d359a9') }

    $body = [ordered]@{
        displayName   = $DisplayName
        state         = $State
        conditions    = @{
            users               = @{ includeUsers = @('All'); excludeGroups = @($ExcludeGroupId.Guid) }
            applications        = $apps
            clientAppTypes      = @('all')
            authenticationFlows = @{ transferMethods = $transfer }   # a flags string, not an array
        }
        grantControls = @{ operator = 'OR'; builtInControls = @('block') }
    }

    if ($PSCmdlet.ShouldProcess($DisplayName, "Create CA policy [$State] blocking $transfer")) {
        $created = Invoke-EptGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' -Body $body
        Get-EptConditionalAccessPolicy | Where-Object PolicyId -eq $created.id
    }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

$breakGlass = '<object id of EmergencyAccess group>'
$pilot      = '<object id of passkey pilot group>'

# Admins: phishing-resistant, report-only
New-EptStrengthPolicy -DisplayName 'CA100 Admins - Require phishing-resistant MFA' -AdminRoles -ExcludeGroupId $breakGlass

# Admins with the custom Tier 0 strength, piped from the strength lookup
Get-EptAuthenticationStrength -Name 'Tier0*' |
    New-EptStrengthPolicy -DisplayName 'CA101 Admins - Require Tier0 keys' -AdminRoles -ExcludeGroupId $breakGlass -WhatIf

# Pilot users
New-EptStrengthPolicy -DisplayName 'CA110 Pilot - Require phishing-resistant MFA' -IncludeGroupId $pilot -ExcludeGroupId $breakGlass

# Device code flow + authentication transfer, report-only
New-EptDeviceCodeBlockPolicy -DisplayName 'CA010 All - Block device code and auth transfer' `
    -ExcludeGroupId $breakGlass -IncludeAuthenticationTransfer

Before switching CA010 to On, find who actually uses device code flow. Get-EptSignInMethod (full source in Part 5) reads the beta sign-in API, which exposes authenticationProtocol:

Get-EptSignInMethod -Days 30 -Protocol deviceCode |
    Group-Object UserPrincipalName, AppDisplayName -NoElement |
    Sort-Object Count -Descending |
    Format-Table Count, Name

Prove it: Test-EptPhishResistantCoverage

This is the function that answers the question from the opening scenario. For every user piped in, it checks:

  • whether an enabled policy requiring a phishing-resistant strength on All resources includes them, directly, through a group, through All users, or through an active directory role;
  • whether an exclusion removes them;
  • whether they have a phishing-resistant method registered, and which phishable methods they still hold.
function Test-EptPhishResistantCoverage {
    <#
    .SYNOPSIS
        For each user, answers: "Is an enforced phishing-resistant policy covering this user, and which
        phishable methods could still satisfy other policies?"
    .DESCRIPTION
        Combines three sources per user:
          1. Registered methods (Get-EptUserRegistration),
          2. Group and active directory-role membership (GET /users/{id}/transitiveMemberOf),
          3. ENABLED Conditional Access policies that require a strength with no phishable combinations
             and target All resources.
        A user is 'Covered' when at least one such policy includes them (All users, directly, by group or
        by role) and no exclusion removes them. Users who are not covered but have phishable methods
        registered are the "weak fallback" population described in Part 3.

        This is a triage aid, not an effective-access engine: it ignores conditions such as platforms,
        locations, client apps and PIM roles that are eligible but not active. Confirm edge cases with
        the Conditional Access What If tool.

        Requires: AuditLog.Read.All, Policy.Read.All, GroupMember.Read.All (or Directory.Read.All).
    .EXAMPLE
        Get-EptPrivilegedUser | Test-EptPhishResistantCoverage | Where-Object Verdict -ne 'Covered'
    .EXAMPLE
        Get-Content pilot-users.txt | Test-EptPhishResistantCoverage | Export-Csv coverage.csv -NoTypeInformation
    #>
    [CmdletBinding()]
    [OutputType('Ept.CoverageResult')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id', 'UserId', 'UPN')]
        [string] $UserPrincipalName
    )
    begin {
        Assert-EptGraphConnection -RequiredScope 'AuditLog.Read.All', 'Policy.Read.All', 'GroupMember.Read.All'
        $strongPolicies = @(Get-EptConditionalAccessPolicy -State enabled |
            Where-Object { $_.RequiresPhishResistant -and $_.Applications -contains 'All' })
        if (-not $strongPolicies) {
            Write-Warning 'No ENABLED policy requires a phishing-resistant strength for All resources. Every user will report NotCovered.'
        }
    }
    process {
        $reg = Get-EptUserRegistration -UserPrincipalName $UserPrincipalName | Select-Object -First 1
        $userId = if ($reg) { $reg.UserId } else { $UserPrincipalName }

        # No $select: groups and directoryRoles have different properties; discriminate on @odata.type.
        $memberOf = @(Invoke-EptGraphRequest -Uri "https://graph.microsoft.com/v1.0/users/$(Resolve-EptUserId $userId)/transitiveMemberOf")
        $groupIds = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.group' } | ForEach-Object id)
        $roleIds = @($memberOf | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.directoryRole' } | ForEach-Object roleTemplateId)

        $covering = foreach ($p in $strongPolicies) {
            $included = $p.IncludeUsers -contains 'All' -or $p.IncludeUsers -contains $userId -or
                        ($p.IncludeGroups | Where-Object { $_ -in $groupIds }) -or
                        ($p.IncludeRoles | Where-Object { $_ -in $roleIds })
            $excluded = $p.ExcludeUsers -contains $userId -or ($p.ExcludeGroups | Where-Object { $_ -in $groupIds })
            if ($included -and -not $excluded) { $p.Name }
        }

        $hasStrong = [bool]$reg.HasPhishingResistant
        $verdict = if ($covering -and $hasStrong) { 'Covered' }
                   elseif ($covering) { 'CoveredButNoMethod' }      # will be blocked or prompted to register
                   elseif ($reg.PhishableMethods) { 'NotCovered-PhishableFallback' }
                   else { 'NotCovered' }

        [pscustomobject]@{
            PSTypeName           = 'Ept.CoverageResult'
            UserPrincipalName    = if ($reg) { $reg.UserPrincipalName } else { $UserPrincipalName }
            Verdict              = $verdict
            CoveringPolicies     = @($covering) -join '; '
            HasPhishingResistant = $hasStrong
            PhishableMethods     = @($reg.PhishableMethods) -join ';'
            ActiveRoleCount      = $roleIds.Count
        }
    }
}

Source: EntraPasskeyToolkit/Public/Enforcement.ps1

# Every admin: are they really covered?
Get-EptPrivilegedUser | Test-EptPhishResistantCoverage | Format-Table UserPrincipalName, Verdict, CoveringPolicies, PhishableMethods

# Pilot readiness before flipping CA110 from report-only to On
Get-Content ./pilot-users.txt | Test-EptPhishResistantCoverage | Group-Object Verdict -NoElement
Verdict Meaning Action
Covered Enforced phishing-resistant policy applies, and the user has a qualifying method None. Keep monitoring usage (Part 5)
CoveredButNoMethod Policy applies, but no phishing-resistant method is registered The user will be blocked or prompted. Issue a TAP and register a passkey (Part 4)
NotCovered-PhishableFallback No enforced policy, and phishable methods are registered This is the gap. Add them to the pilot or policy scope
NotCovered No enforced policy and no phishable methods Usually a new or unregistered user. Onboard with TAP

It is a triage tool, not an effective-access engine. It ignores platform, location and client-app conditions, and PIM roles that are eligible but not active. Confirm edge cases with the What If tool in Conditional Access.

Best practices

  1. Require an authentication strength, not "MFA", for anything you care about. Generic MFA accepts SMS.
  2. Exclude break-glass accounts from every policy that can block, using a dedicated group. Report-only policies don't need the exclusion (Part 6).
  3. Report-only → small enforced cohort → expand. Review Failure and User action required results at each step.
  4. Enforce by OS group when you reach the general workforce, so one platform's gap doesn't stall the rest.
  5. Block device code flow and authentication transfer unless you have a documented, scoped exception.
  6. Remove phishable methods from privileged users' method policy once they're covered. Even unused, they're an attack path for SSPR and help-desk social engineering.
  7. Snapshot policies before every change and keep the JSON with the change record.
  8. Verify outcomes, not configuration: a prepared pilot user reaches their apps with a passkey, and the same user fails with push or SMS.

Next, Part 4: the new weak point. How users get their first passkey, and how an attacker is prevented from getting one on their behalf.

References