BitLocker Keys Backup to Azure Key Vault

This script connects to Microsoft Graph API to retrieve BitLocker recovery keys for Windows devices, then stores them securely in Azure Key Vault using REST API. Each key is stored as a secret with device information (name and serial number) included in tags. Authentication uses the MgGraphCommunity module (WAM-free) and acquires two separate tokens with the correct audiences: a device code sign-in for Azure Key Vault (https://vault.azure.net) and an interactive browser sign-in for Microsoft Graph. No Az modules are needed. On first run, you will be prompted to consent to the required permissions including Key Vault access.

SecurityCompliance
193 views48 downloads1 runbook deployedVersion 1.5By Ugur Koc
View on GitHub

New to runbook deployment? Follow the step-by-step guide from the Deploy to Azure button to the first scheduled run, including granting Graph permissions to the managed identity.

// QUALITY CHECKS

Validation status

Quality checks

All checks pass
  • ParsePass
  • LintPass
  • MetadataPass
  • Runbook-readyPass
  • Module depsPass

Tests run automatically on every change. What does each check mean?

// REQUIRED PERMISSIONS

Microsoft Graph scopes

DeviceManagementManagedDevices.Read.All

Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.

BitlockerKey.Read.All

Allows an app to read BitLocker keys for all devices, without a signed-in user. Allows read of the recovery key.

Running this as an Azure Automation runbook? These scopes must be granted to the account's managed identity, which has no portal UI. The deployment walkthrough shows the exact Cloud Shell commands.

// CHANGELOG

Version history

  1. Entry · 01

    1.5 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors

  2. Entry · 02

    1.4 - Azure Automation now records script progress, outcomes, and summaries in job history

  3. Entry · 03

    1.3 - Renamed the automation detection variable to the name the CI runbook-readiness check recognizes; no functional change (the script already refused to run as a runbook before any prompt)

  4. Entry · 04

    1.2 - A failure on one key no longer discards a device's other keys: successfully fetched keys are kept and the device is reported as Partial; secret names now always carry the volume type suffix so they stay stable across runs (previously the suffix was only added when multiple keys existed; unsuffixed secrets written by earlier versions remain untouched); results table now shows the Key Vault secret version

  5. Entry · 05

    1.1 - Reworked authentication: MgGraphCommunity acquires separate Graph and Key Vault audience tokens (WAM-free). Fixed key retrieval: keys are now read from the Entra ID recovery key store (informationProtection/bitlocker); the previous Intune-side path checked a nonexistent property and could never return keys

  6. Entry · 06

    1.0 - Initial release

// CODE

Source

backup-bitlocker-keys-to-keyvault.ps1
<#
.TITLE
    BitLocker Keys Backup to Azure Key Vault

.SYNOPSIS
    Backs up BitLocker recovery keys from Entra ID (Azure AD) to Azure Key Vault using REST API.

.DESCRIPTION
    This script connects to Microsoft Graph API to retrieve BitLocker recovery keys for Windows devices,
    then stores them securely in Azure Key Vault using REST API. Each key is stored as a secret with
    device information (name and serial number) included in tags.
    Authentication uses the MgGraphCommunity module (WAM-free) and acquires two separate tokens with
    the correct audiences: a device code sign-in for Azure Key Vault (https://vault.azure.net) and an
    interactive browser sign-in for Microsoft Graph. No Az modules are needed. On first run, you will
    be prompted to consent to the required permissions including Key Vault access.

.TAGS
    Security,Compliance

.PLATFORM
    Windows

.MINROLE
    Intune Administrator, Key Vault Secrets Officer (ABAC) or Key Vault Administrator

.PERMISSIONS
    DeviceManagementManagedDevices.Read.All,BitlockerKey.Read.All

.EXECUTION
    LocalOnly

.AUTHOR
    Ugur Koc

.VERSION
    1.5

.CHANGELOG
    1.5 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors
    1.4 - Azure Automation now records script progress, outcomes, and summaries in job history
    1.3 - Renamed the automation detection variable to the name the CI runbook-readiness check recognizes; no functional change (the script already refused to run as a runbook before any prompt)
    1.2 - A failure on one key no longer discards a device's other keys: successfully fetched keys are kept and the device is reported as Partial; secret names now always carry the volume type suffix so they stay stable across runs (previously the suffix was only added when multiple keys existed; unsuffixed secrets written by earlier versions remain untouched); results table now shows the Key Vault secret version
    1.1 - Reworked authentication: MgGraphCommunity acquires separate Graph and Key Vault audience tokens (WAM-free). Fixed key retrieval: keys are now read from the Entra ID recovery key store (informationProtection/bitlocker); the previous Intune-side path checked a nonexistent property and could never return keys
    1.0 - Initial release

.LASTUPDATE
    2026-07-30

.EXAMPLE
    .\backup-bitlocker-keys-to-keyvault.ps1 -VaultUri "https://bitlockerfilevaultkeys.vault.azure.net"
    Backs up all BitLocker keys to the specified Azure Key Vault

.EXAMPLE
    .\backup-bitlocker-keys-to-keyvault.ps1 -VaultUri "https://myvault.vault.azure.net" -OverwriteExisting "true" -ShowProgress "true"
    Backs up keys with overwrite option and progress display


.NOTES
    - Requires Microsoft.Graph.Authentication and MgGraphCommunity modules (no Az modules needed; auto-installed if missing)
    - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows
    - Two sign-ins are required per session: a device code sign-in for Key Vault and a browser sign-in for Graph (the two APIs need tokens with different audiences)
    - Interactive only: this script cannot run as an Azure Automation runbook
    - Uses REST API directly for Key Vault operations
    - Keys are stored with naming convention: BitLocker-{DeviceName}-{SerialNumber}-{VolumeType}
    - Each secret includes tags for easy identification and management
    - Consider implementing retention policies in Key Vault
    - Regular backups ensure recovery key availability
    - Vault URI format: https://yourvault.vault.azure.net

    PERMISSION CONSENT:
    On first run, you'll be prompted to consent to the following permissions:
    - Azure Key Vault access (https://vault.azure.net/user_impersonation)
    - Read Intune devices (DeviceManagementManagedDevices.Read.All)
    - Read BitLocker keys (BitlockerKey.Read.All)

    To avoid the consent prompt:
    - Accept once and check "Consent on behalf of your organization" (admin only)
    - Pre-consent in Azure AD portal under Enterprise Applications
    - For automation, use a service principal with pre-configured permissions
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true, HelpMessage = "Azure Key Vault URI (e.g., https://myvault.vault.azure.net)")]
    [ValidateNotNullOrEmpty()]
    [ValidatePattern('^https://[a-zA-Z0-9-]+\.vault\.azure\.net/?$')]
    [string]$VaultUri,

    [Parameter(Mandatory = $false, HelpMessage = "Overwrite existing secrets in Key Vault")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$OverwriteExisting,

    [Parameter(Mandatory = $false, HelpMessage = "Show progress during processing")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$ShowProgress,

    [Parameter(Mandatory = $false, HelpMessage = "Entra tenant ID or domain to sign in to (recommended when you have access to multiple tenants, so both sign-ins land in the same tenant)")]
    [string]$TenantId = "",

    [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
    [ValidateSet("true", "false", "1", "0", '$true', '$false')]
    [string]$ForceModuleInstall
)

# Normalize the local module-install override for Azure Automation parameter binding.
$forceModuleInstallRaw = [string]$ForceModuleInstall
Remove-Variable -Name ForceModuleInstall
if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) {
    $ForceModuleInstall = $false
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) {
    $ForceModuleInstall = $true
}
elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) {
    $ForceModuleInstall = $false
}
else {
    throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false."
}

# Azure Automation supplies portal parameter values as strings. Normalize the
# public boolean parameters once so local and runbook execution use real booleans.
foreach ($runbookBooleanParameter in @('OverwriteExisting', 'ShowProgress')) {
    $runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly)
    Remove-Variable -Name $runbookBooleanParameter

    if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) {
        Set-Variable -Name $runbookBooleanParameter -Value $false
        continue
    }

    switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) {
        { $_ -in @("true", "1", '$true') } {
            Set-Variable -Name $runbookBooleanParameter -Value $true
        }
        { $_ -in @("false", "0", '$false') } {
            Set-Variable -Name $runbookBooleanParameter -Value $false
        }
        default {
            throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false."
        }
    }
}

# ============================================================================
# ENVIRONMENT DETECTION AND SETUP
# ============================================================================

# This script needs two interactive sign-ins and therefore cannot run as a runbook
$IsAutomationEnvironment = $null -ne $PSPrivateMetadata.JobId.Guid
if ($IsAutomationEnvironment) {
    Write-Error "This script requires interactive sign-in (Graph and Key Vault) and cannot run as an Azure Automation runbook."
    exit 1
}

function Initialize-RequiredModule {
    param(
        [hashtable]$Modules,
        [bool]$ForceInstall = $false
    )

    foreach ($ModuleName in $Modules.Keys) {
        $minVersion = $Modules[$ModuleName]
        $module = Get-Module -ListAvailable -Name $ModuleName |
            Where-Object { -not $minVersion -or $_.Version -ge [version]$minVersion } |
            Sort-Object Version -Descending | Select-Object -First 1
        if (-not $module) {
            $needed = if ($minVersion) { "$ModuleName (>= $minVersion)" } else { $ModuleName }
            Write-Information "Module $needed not found. Installing..." -InformationAction Continue
            if (-not $ForceInstall) {
                $response = Read-Host "Install module '$needed'? (Y/N)"
                if ($response -notmatch '^[Yy]') {
                    throw "Module '$needed' is required but installation was declined."
                }
            }
            $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
            $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" }
            $installParams = @{ Name = $ModuleName; Scope = $scope; Force = $true; AllowClobber = $true; Repository = 'PSGallery' }
            if ($minVersion) { $installParams.MinimumVersion = $minVersion }
            Install-Module @installParams
        }
        $importParams = @{ Name = $ModuleName; Force = $true; ErrorAction = 'Stop' }
        if ($minVersion) { $importParams.MinimumVersion = $minVersion }
        Import-Module @importParams
    }
}

# MgGraphCommunity 1.4.0 introduced multi-session context switching, which the
# Graph/Key Vault token toggle below depends on
$RequiredModules = @{
    "Microsoft.Graph.Authentication" = $null
    "MgGraphCommunity"               = "1.4.0"
}

try {
    Initialize-RequiredModule -Modules $RequiredModules -ForceInstall $ForceModuleInstall
}
catch {
    Write-Error "Module initialization failed: $_"
    exit 1
}

# Ensure VaultUri ends without trailing slash for consistency
$VaultUri = $VaultUri.TrimEnd('/')

# ============================================================================
# AUTHENTICATION
# ============================================================================

# Graph and Key Vault are different resources, so one token cannot serve both.
# Two MgGraphCommunity sessions are used (WAM-free):
#   1. Device code sign-in scoped to https://vault.azure.net (Key Vault audience)
#   2. Interactive browser sign-in for Microsoft Graph
# Selecting a session also re-points the SDK handoff (Invoke-MgGraphRequest), so
# the Graph session must be active whenever Graph is called. Set-KeyVaultSecret
# switches to the vault session for each Key Vault call and switches back after.
try {
    Write-Output "Step 1/2: Sign in for Azure Key Vault access (device code)..."
    $vaultConnect = @{
        UseDeviceCode = $true
        Scopes        = @("https://vault.azure.net/user_impersonation")
        NoWelcome     = $true
        ErrorAction   = 'Stop'
    }
    if ($TenantId) { $vaultConnect.TenantId = $TenantId }
    Connect-MgGraphCommunity @vaultConnect

    $vaultSession = Get-MgGraphCommunityContext -ListAvailable |
        Where-Object { $_.FlowType -eq 'DeviceCode' } | Select-Object -First 1
    if (-not $vaultSession) {
        throw "Key Vault session not found after device code sign-in."
    }

    Write-Output "Step 2/2: Sign in for Microsoft Graph access (browser)..."
    $Scopes = @(
        "DeviceManagementManagedDevices.Read.All",
        "BitlockerKey.Read.All"
    )
    $graphConnect = @{
        Scopes      = $Scopes
        NoWelcome   = $true
        ErrorAction = 'Stop'
    }
    if ($TenantId) { $graphConnect.TenantId = $TenantId }
    Connect-MgGraphCommunity @graphConnect

    $graphSession = Get-MgGraphCommunityContext -ListAvailable |
        Where-Object { $_.FlowType -eq 'Interactive' } | Select-Object -First 1
    if (-not $graphSession) {
        throw "Graph session not found after interactive sign-in."
    }

    # Both cache keys are needed to toggle sessions per call in Set-KeyVaultSecret
    $script:VaultCacheKey = $vaultSession.CacheKey
    $script:GraphCacheKey = $graphSession.CacheKey
    Write-Output "✓ Successfully connected to Microsoft Graph and Azure Key Vault"
}
catch {
    Write-Error "Failed to connect: $($_.Exception.Message)"
    exit 1
}

# ============================================================================
# HELPER FUNCTIONS
# ============================================================================

# Function to get all pages of results from Graph API
function Get-MgGraphAllPage {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,
        [int]$DelayMs = 100
    )

    $AllResults = @()
    $NextLink = $Uri
    $RequestCount = 0

    do {
        try {
            # Add delay to respect rate limits
            if ($RequestCount -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }

            $Response = Invoke-MgGraphRequest -Uri $NextLink -Method GET
            $RequestCount++

            if ($null -ne $Response.value) {
                $AllResults += $Response.value
            }
            else {
                $AllResults += $Response
            }

            $NextLink = $Response.'@odata.nextLink'
        }
        catch {
            if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") {
                Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue
                Start-Sleep -Seconds 60
                continue
            }
            throw "Error fetching data from $NextLink : $($_.Exception.Message)"
        }
    } while ($NextLink)

    return $AllResults
}

# Function to get BitLocker recovery keys from the Entra ID recovery key store.
# BitLocker keys escrowed by Intune live in Entra ID and are read via
# /informationProtection/bitlocker/recoveryKeys filtered on the Entra device ID.
function Get-BitLockerRecoveryKeyFromAzureAD {
    param (
        [Parameter(Mandatory = $true)]
        [string]$AzureADDeviceId,
        [Parameter(Mandatory = $false)]
        [string]$DeviceName = "Unknown"
    )

    try {
        # Get the key IDs from Azure AD
        $keyIdUri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys?`$filter=deviceId eq '$AzureADDeviceId'"
        $keyIdResponse = Invoke-MgGraphRequest -Uri $keyIdUri -Method GET
    }
    catch {
        Write-Warning "Error retrieving BitLocker key list from Azure AD for device $DeviceName : $($_.Exception.Message)"
        return $null
    }

    if ($keyIdResponse.value.Count -eq 0) {
        Write-Verbose "No BitLocker keys found in Azure AD for device $DeviceName"
        return $null
    }

    # Fetch each key individually so one failure does not discard the
    # keys that were already retrieved successfully
    $keys = @()
    $failedKeyCount = 0
    foreach ($keyInfo in $keyIdResponse.value) {
        try {
            # Get the actual recovery key
            $keyUri = "https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys/$($keyInfo.id)?`$select=key"
            $keyResponse = Invoke-MgGraphRequest -Uri $keyUri -Method GET

            $keys += @{
                Id = $keyInfo.id
                Key = $keyResponse.key
                VolumeType = $keyInfo.volumeType
                CreatedDateTime = $keyInfo.createdDateTime
            }
        }
        catch {
            Write-Warning "Error retrieving BitLocker key $($keyInfo.id) from Azure AD for device $DeviceName : $($_.Exception.Message)"
            $failedKeyCount++
        }
    }

    return @{
        Keys = $keys
        FailedKeyCount = $failedKeyCount
    }
}

# Function to create or update secret in Key Vault using REST API.
# Uses Invoke-MgGraphCommunityRequest, whose active session carries the Key
# Vault-audience token (selected in the authentication block above).
function Set-KeyVaultSecret {
    param(
        [Parameter(Mandatory = $true)]
        [string]$SecretName,
        [Parameter(Mandatory = $true)]
        [string]$SecretValue,
        [Parameter(Mandatory = $true)]
        [hashtable]$Tags,
        [Parameter(Mandatory = $true)]
        [string]$VaultUri
    )

    try {
        # Sanitize secret name (remove invalid characters)
        $SecretName = $SecretName -replace '[^a-zA-Z0-9-]', '-'

        $uri = "$VaultUri/secrets/$SecretName`?api-version=7.4"

        # Route the Key Vault calls with the vault-audience session
        Select-MgGraphCommunityContext -CacheKey $script:VaultCacheKey | Out-Null

        # Key Vault creates a new secret version on every PUT, so honor
        # -OverwriteExisting by checking for the secret first
        if (-not $OverwriteExisting) {
            $existing = $null
            try {
                $existing = Invoke-MgGraphCommunityRequest -Method GET -Uri $uri
            }
            catch {
                # Secret does not exist (or is not readable) - proceed with create
                $existing = $null
            }
            if ($existing) {
                return @{
                    Success = $false
                    Error = "Secret already exists. Use -OverwriteExisting to update."
                }
            }
        }

        $body = @{
            value = $SecretValue
            tags = $Tags
            attributes = @{
                enabled = $true
            }
        }

        $response = Invoke-MgGraphCommunityRequest -Method PUT -Uri $uri -Body $body

        return @{
            Success = $true
            SecretId = $response.id
            # The secret version is the trailing segment of the secret id URI
            Version = ($response.id -split '/')[-1]
        }
    }
    catch {
        return @{
            Success = $false
            Error = $_.Exception.Message
        }
    }
    finally {
        # Restore the Graph session so Invoke-MgGraphRequest keeps the Graph token
        try {
            Select-MgGraphCommunityContext -CacheKey $script:GraphCacheKey | Out-Null
        }
        catch {
            Write-Warning "Failed to restore the Graph session: $($_.Exception.Message)"
        }
    }
}

# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================

try {
    Write-Output "Starting BitLocker keys backup to Azure Key Vault..."

    # Get all Windows devices from Intune
    Write-Output "Retrieving Windows devices from Intune..."
    $devicesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=id,deviceName,serialNumber,azureADDeviceId,model,manufacturer,isEncrypted"
    $devices = Get-MgGraphAllPage -Uri $devicesUri

    if ($devices.Count -eq 0) {
        Write-Warning "No Windows devices found in Intune"
        return
    }

    Write-Output "Found $($devices.Count) Windows devices. Processing BitLocker keys..."

    $results = @()
    $processedCount = 0
    $successCount = 0
    $failedCount = 0
    $skippedCount = 0

    foreach ($device in $devices) {
        $processedCount++

        if ($ShowProgress) {
            $percentComplete = [math]::Round(($processedCount / $devices.Count) * 100, 1)
            Write-Progress -Activity "Backing up BitLocker Keys" -Status "Processing device: $($device.deviceName)" -PercentComplete $percentComplete
        }

        # Devices without BitLocker or without an Entra device ID cannot have escrowed keys
        if (-not $device.isEncrypted -or -not $device.azureADDeviceId) {
            $reason = if (-not $device.isEncrypted) { "Not BitLocker Encrypted" } else { "No Entra Device ID" }
            Write-Verbose "Skipping device $($device.deviceName): $reason"
            $results += [PSCustomObject]@{
                DeviceName = $device.deviceName
                SerialNumber = $device.serialNumber
                Status = $reason
                KeyVaultSecret = "N/A"
                Version = "N/A"
                Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            }
            $skippedCount++
            continue
        }

        # Get BitLocker recovery keys from the Entra ID recovery key store
        $keyResult = Get-BitLockerRecoveryKeyFromAzureAD -AzureADDeviceId $device.azureADDeviceId -DeviceName $device.deviceName

        if (-not $keyResult) {
            Write-Verbose "No BitLocker keys found for device: $($device.deviceName)"
            $results += [PSCustomObject]@{
                DeviceName = $device.deviceName
                SerialNumber = $device.serialNumber
                Status = "No Keys Found"
                KeyVaultSecret = "N/A"
                Version = "N/A"
                Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            }
            $skippedCount++
            continue
        }

        foreach ($recoveryKey in $keyResult.Keys) {
            # Volume type is always part of the name so it stays stable across runs
            $secretName = "BitLocker-$($device.deviceName)-$($device.serialNumber)-$($recoveryKey.VolumeType)"

            $tags = @{
                DeviceName = if ($device.deviceName) { $device.deviceName } else { "Unknown" }
                SerialNumber = if ($device.serialNumber) { $device.serialNumber } else { "NoSerial" }
                AzureADDeviceId = $device.azureADDeviceId
                VolumeType = $recoveryKey.VolumeType
                Model = if ($device.model) { $device.model } else { "Unknown" }
                Manufacturer = if ($device.manufacturer) { $device.manufacturer } else { "Unknown" }
                BackupDate = (Get-Date -Format "yyyy-MM-dd")
                Source = "IntuneAutomation"
            }

            # Store in Key Vault
            $kvResult = Set-KeyVaultSecret -SecretName $secretName -SecretValue $recoveryKey.Key -Tags $tags -VaultUri $VaultUri

            if ($kvResult.Success) {
                Write-Output "✓ Successfully backed up key for: $($device.deviceName)"
                $successCount++
                $status = "Success"
            }
            else {
                Write-Warning "✗ Failed to backup key for $($device.deviceName): $($kvResult.Error)"
                $failedCount++
                $status = "Failed: $($kvResult.Error)"
            }

            $results += [PSCustomObject]@{
                DeviceName = $device.deviceName
                SerialNumber = $device.serialNumber
                Status = $status
                KeyVaultSecret = $secretName
                Version = if ($kvResult.Success) { $kvResult.Version } else { "N/A" }
                Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            }
        }

        # Some keys could not be retrieved: record the device as Partial
        if ($keyResult.FailedKeyCount -gt 0) {
            Write-Warning "✗ $($keyResult.FailedKeyCount) key(s) could not be retrieved for $($device.deviceName)"
            $failedCount++
            $results += [PSCustomObject]@{
                DeviceName = $device.deviceName
                SerialNumber = $device.serialNumber
                Status = "Partial: $($keyResult.FailedKeyCount) key(s) could not be retrieved"
                KeyVaultSecret = "N/A"
                Version = "N/A"
                Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
            }
        }
    }

    if ($ShowProgress) {
        Write-Progress -Activity "Backing up BitLocker Keys" -Completed
    }

    # Display results
    Write-Output "`nBitLocker Keys Backup Results:"
    $results | Format-Table -AutoSize

    Write-Output "✓ BitLocker keys backup completed successfully"
}
catch {
    Write-Error "Script failed: $($_.Exception.Message)"
    exit 1
}
finally {
    # Cleanup operations - clear both the SDK context and all MgGraphCommunity
    # sessions (drops the in-memory Graph and Key Vault tokens)
    try {
        Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        Disconnect-MgGraphCommunity -ErrorAction SilentlyContinue | Out-Null
        Write-Output "Disconnected from Microsoft Graph and Azure Key Vault"
    }
    catch {
        # Ignore disconnect errors
    }
}

# ============================================================================
# SCRIPT SUMMARY
# ============================================================================

Write-Output "
========================================
Script Execution Summary
========================================
Script: BitLocker Keys Backup to Key Vault
Total Devices Processed: $processedCount
Successfully Backed Up: $successCount
Failed: $failedCount
Skipped (No Keys): $skippedCount
Key Vault: $VaultUri
Status: Completed
========================================
"

// NOTES

Author notes

- Requires Microsoft.Graph.Authentication and MgGraphCommunity modules (no Az modules needed; auto-installed if missing) - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows - Two sign-ins are required per session: a device code sign-in for Key Vault and a browser sign-in for Graph (the two APIs need tokens with different audiences) - Interactive only: this script cannot run as an Azure Automation runbook - Uses REST API directly for Key Vault operations - Keys are stored with naming convention: BitLocker-{DeviceName}-{SerialNumber}-{VolumeType} - Each secret includes tags for easy identification and management - Consider implementing retention policies in Key Vault - Regular backups ensure recovery key availability - Vault URI format: https://yourvault.vault.azure.net PERMISSION CONSENT: On first run, you'll be prompted to consent to the following permissions: - Azure Key Vault access (https://vault.azure.net/user_impersonation) - Read Intune devices (DeviceManagementManagedDevices.Read.All) - Read BitLocker keys (BitlockerKey.Read.All) To avoid the consent prompt: - Accept once and check "Consent on behalf of your organization" (admin only) - Pre-consent in Azure AD portal under Enterprise Applications - For automation, use a service principal with pre-configured permissions

// RELATED

Picked by shared tags, category, and script type — nothing magic, just metadata overlap.

  1. Multi-Admin Approval Compliance Dashboard Report

    This script connects to Microsoft Graph and analyzes Multi-Admin Approval configurations, usage patterns, and compliance metrics across your Intune environment. It generates detailed reports showing MAA coverage gaps, approval statistics, admin permissions, and trends. The script helps organizations ensure proper implementation of MAA controls and identify areas for security improvement. Reports are generated in both HTML and CSV formats for different audiences.

    ComplianceSecurity
  2. Multi-Admin Approval Pending Requests Monitor

    This script is designed to run as a scheduled Azure Automation runbook that monitors Multi-Admin Approval requests in Microsoft Intune and identifies pending approval requests. It tracks new requests, monitors request age, identifies approvers, and sends email notifications to administrators with detailed request information and direct links to the Intune portal. The script helps maintain security compliance by ensuring timely review of administrative changes and provides visibility into the MAA approval workflow. Key Features: - Monitors all MAA pending requests across protected resources - Tracks request age and highlights urgent requests - Identifies and notifies appropriate approvers - Provides direct links to Intune portal for quick action - Tracks previously notified requests to avoid spam - Sends escalation alerts for aging requests - Supports both Azure Automation runbook and local execution - HTML formatted email reports with actionable insights - Uses Microsoft Graph Mail API exclusively

    SecurityCompliance
  3. Get Compliance Policy Coverage

    This script compares the platforms of all enrolled Intune devices against the platforms targeted by assigned compliance policies. Platforms with enrolled devices but no assigned compliance policy are a real gap: those devices report as compliant by default (or fall to the built-in policy) and can slip through Conditional Access checks. The report also lists compliance policies that exist but are not assigned to anything, and the device count per platform so gaps can be prioritized.

    SecurityCompliance