86°F Knoxville
Mon–Fri 9–5 ETAnswered by a human
Automation · RESOLVED

BitLocker Encryption at Scale: Compliance in 72 Hours

Cyber insurance audit required full-disk encryption on 127 endpoints. Manual BitLocker: 15 min per machine. Automated: zero-touch with recovery keys synced to RMM.

Incident

Cyber insurance renewal audit flagged a critical finding on a Tuesday: full-disk encryption was required on all endpoints within 72 hours or the policy would lapse. The client had 127 Windows workstations across 3 locations — zero had BitLocker enabled. Their previous MSP had quoted "6-8 weeks" for manual deployment. The insurance carrier was not interested in waiting 6-8 weeks.

The manual approach problem

Manual BitLocker deployment is expensive in clock time and technician time:

  • per machine: 15-20 minutes hands-on
  • recovery key: must be recorded somewhere secure
  • TPM check: varies by hardware generation
  • encryption time: 30-90 minutes depending on drive size

At 15 minutes of hands-on time per machine (not counting the encryption clock that runs in the background), 127 endpoints is 32 hours of technician time. Plus the nightmare of securely tracking 127 recovery keys. With 72 total hours and only business hours available, there was no version of manual that got there.

Why BitLocker automation is tricky

Most "BitLocker scripts" you find online fail on at least one of these:

  • TPM requirements: must verify the chip is both present and ready
  • protector types: TPM alone vs TPM+PIN vs USB key vs combinations
  • recovery key: 48-digit key must be escrowed securely somewhere we can find it
  • existing protectors: duplicate protectors cause Enable-BitLocker to error out
  • SYSTEM context: RMM runs as SYSTEM, not as the user, which changes some BitLocker APIs

Scripts online tend to assume TPM is ready, skip protector cleanup, or leave recovery key escrow as "an exercise for the reader." Ours validates TPM, rotates protectors, and syncs the recovery key directly to RMM custom fields as part of the same run.

Solution

Deploy via RMM to all 127 endpoints in parallel. The script validates TPM, cleans up existing protectors, adds a fresh TPM protector and a numerical recovery password, initiates XTS-AES-256 encryption, and writes the recovery key to the asset's custom field automatically. Zero manual intervention.

bitlocker_superops_enable.ps1View on GitHub →
Import-Module $SuperOpsModule
$ErrorActionPreference = 'Stop'

<#
██╗     ██╗███╗   ███╗███████╗██╗  ██╗ █████╗ ██╗    ██╗██╗  ██╗
██║     ██║████╗ ████║██╔════╝██║  ██║██╔══██╗██║    ██║██║ ██╔╝
██║     ██║██╔████╔██║█████╗  ███████║███████║██║ █╗ ██║█████╔╝
██║     ██║██║╚██╔╝██║██╔══╝  ██╔══██║██╔══██║██║███╗██║██╔═██╗
███████╗██║██║ ╚═╝ ██║███████╗██║  ██║██║  ██║╚███╔███╔╝██║  ██╗
╚══════╝╚═╝╚═╝     ╚═╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝ ╚══╝╚══╝ ╚═╝  ╚═╝
================================================================================
 SCRIPT    : BitLocker Enable with SuperOps Integration                  v1.3.1
 AUTHOR    : Limehawk.io
 DATE      : January 2026
 USAGE     : .\bitlocker_superops_enable.ps1
================================================================================
 FILE      : bitlocker_superops_enable.ps1
 DESCRIPTION : Enables BitLocker with TPM and syncs recovery key to SuperOps
--------------------------------------------------------------------------------
 README
--------------------------------------------------------------------------------
 PURPOSE

   Enables BitLocker on the OS drive using TPM and Recovery Password protectors.
   Ensures protectors are not duplicated, prints recovery key to console for RMM
   log capture, and syncs the recovery key to SuperOps custom fields.

 DATA SOURCES & PRIORITY

   1) Hardcoded values (drive letter, force flag, encryption settings)
   2) TPM status via Get-Tpm cmdlet
   3) BitLocker volume status via Get-BitLockerVolume
   4) CIM for volume and recovery password retrieval
   5) SuperOps custom fields for recovery key storage

 REQUIRED INPUTS

   All inputs are hardcoded in the script body:
   - $SuperOpsModule : Path to SuperOps module (injected by RMM; non-empty string)
   - $Drive          : Target drive for BitLocker (default: 'C:')
   - $Force          : Force recovery key rotation (default: $true)

 SETTINGS

   Configuration details and default values:
   - Encryption Method : XTS-AES 256
   - Used Space Only   : $true (faster encryption)
   - Skip Hardware Test: $true (no reboot required to start)

 BEHAVIOR

   The script performs the following actions in order:
   1. Validates administrative privileges and SuperOps module
   2. Ensures BDESVC (BitLocker Service) is running and set to Automatic
   3. Checks TPM status and adds TPM protector if available
   4. Rotates Recovery Password protector when Force=$true
   5. Initiates BitLocker encryption with configured settings
   6. Retrieves and displays recovery key
   7. Syncs recovery key ID and password to SuperOps custom fields

 PREREQUISITES

   - PowerShell 5.1 or later
   - Windows 10/11 Pro or Enterprise
   - BitLocker feature enabled
   - Administrator privileges required
   - SuperOps module available via $SuperOpsModule

 SECURITY NOTES

   - Recovery Password is printed to console (captured by RMM logs)
   - Recovery Password is synced to SuperOps custom fields
   - No local files or transcripts are created

 ENDPOINTS

   - Local TPM (Trusted Platform Module)
   - Local BitLocker Service (BDESVC)
   - SuperOps API via Send-CustomField cmdlet

 EXIT CODES

   0 = Success - BitLocker enabled and key synced
   1 = Failure - prerequisites, validation, or cmdlet errors

 EXAMPLE RUN

   [INFO] INPUT VALIDATION
   ==============================================================
     SuperOpsModule   : C:\Program Files\SuperOps\Modules\SuperOps.psm1
     Drive            : C:
     Force            : True
     Admin Privileges : Confirmed

   [INFO] PRECHECK
   ==============================================================
     BDESVC Status    : Running
     TPM Present      : True
     TPM Ready        : True
     Current Status   : FullyDecrypted

   [RUN] CONFIGURE PROTECTORS
   ==============================================================
     Removing 1 existing RecoveryPassword protector(s)
     Removed old protector : {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}
     Added new RecoveryPassword protector
     Recovery Key ID  : {YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}
     TPM protector already present

   [RUN] ENABLE ENCRYPTION
   ==============================================================
     Encryption already active - skipping enable
     Volume Status    : FullyEncrypted

   [INFO] RECOVERY KEY
   ==============================================================
     Recovery Key ID  : {YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY}
     Recovery Password:
     123456-789012-345678-901234-567890-123456-789012-345678

   [RUN] SUPEROPS SYNC
   ==============================================================
     Sent BitLocker Recovery Key ID to SuperOps
     Sent BitLocker Recovery Password to SuperOps
     Custom fields synchronized

   [OK] FINAL STATUS
   ==============================================================
     Result           : SUCCESS
     BitLocker enablement completed

   [OK] SCRIPT COMPLETED
   ==============================================================

--------------------------------------------------------------------------------
 CHANGELOG
--------------------------------------------------------------------------------
 2026-01-19 v1.3.1 Updated to two-line ASCII console output style
 2026-01-19 v1.3.0 Updated to corner bracket style section headers
 2025-12-23 v1.2.0 Updated to Limehawk Script Framework
 2025-11-29 v1.1.0 Refactored to Style A with SuperOps integration, improved error handling
 2025-09-29 v1.0.0 Initial Style A implementation with hardcoded inputs
#>

Set-StrictMode -Version Latest

# ==============================================================================
# STATE VARIABLES
# ==============================================================================

$errorOccurred  = $false
$errorText      = ""
$recoveryKey    = ""
$recoveryKeyId  = ""

# ==============================================================================
# HARDCODED INPUTS
# ==============================================================================

$Drive = 'C:'
$Force = $true  # Force recovery key rotation/creation

# Encryption settings
$EncryptionMethod = 'XtsAes256'
$UsedSpaceOnly    = $true
$SkipHardwareTest = $true

# ==============================================================================
# HELPER FUNCTION
# ==============================================================================

function Get-RecoveryPasswordForId {
    param([string]$MountPoint, [string]$KeyProtectorId)

    # Try CIM/WMI method first
    try {
        $volume = Get-CimInstance -Namespace "root\CIMV2\Security\MicrosoftVolumeEncryption" `
            -ClassName "Win32_EncryptableVolume" -ErrorAction Stop |
            Where-Object { $_.DriveLetter -eq $MountPoint }

        if ($volume) {
            $result = Invoke-CimMethod -InputObject $volume -MethodName "GetKeyProtectorNumericalPassword" `
                -Arguments @{ VolumeKeyProtectorID = $KeyProtectorId } -ErrorAction Stop
            if ($result.NumericalPassword) {
                return $result.NumericalPassword
            }
        }
    } catch {
        # Fall through to manage-bde fallback
    }

    # Fallback to manage-bde parsing
    try {
        $output = & manage-bde -protectors -get $MountPoint 2>$null
        $currentId = $null

        foreach ($line in $output) {
            # Match protector ID (with or without braces)
            if ($line -match 'ID:\s*\{?([0-9A-Fa-f\-]{36})\}?') {
                $currentId = $Matches[1]
            }
            # Match recovery password pattern
            if ($line -match '([0-9]{6}-){7}[0-9]{6}') {
                $cleanKeyId = $KeyProtectorId -replace '[{}]', ''
                if ($currentId -eq $cleanKeyId) {
                    return $Matches[0]
                }
            }
        }
    } catch {
        # Return null if all methods fail
    }

    return $null
}

# ==============================================================================
# INPUT VALIDATION
# ==============================================================================

Write-Host ""
Write-Host "[INFO] INPUT VALIDATION"
Write-Host "=============================================================="

# Validate SuperOps module
if ([string]::IsNullOrWhiteSpace($SuperOpsModule)) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- SuperOpsModule path is required (injected by RMM)"
}

# Validate drive
if ([string]::IsNullOrWhiteSpace($Drive)) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- Drive is required"
}

# Validate Force is boolean
if ($null -eq $Force -or $Force -isnot [bool]) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- Force must be a boolean (\$true or \$false)"
}

# Validate admin privileges
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
    $errorOccurred = $true
    if ($errorText.Length -gt 0) { $errorText += "`n" }
    $errorText += "- Script must be run with Administrator privileges"
}

if ($errorOccurred) {
    Write-Host ""
    Write-Host "[ERROR] ERROR OCCURRED"
    Write-Host "=============================================================="
    Write-Host "  Input validation failed:"
    Write-Host "  $errorText"
    Write-Host ""
    Write-Host "  Troubleshooting:"
    Write-Host "  - Ensure SuperOps module is configured in RMM environment"
    Write-Host "  - Run script with Administrator privileges"
    exit 1
}

Write-Host "  SuperOpsModule   : $SuperOpsModule"
Write-Host "  Drive            : $Drive"
Write-Host "  Force            : $Force"
Write-Host "  Admin Privileges : Confirmed"

# ==============================================================================
# PRECHECK
# ==============================================================================

Write-Host ""
Write-Host "[INFO] PRECHECK"
Write-Host "=============================================================="

# Check drive exists
try {
    $vol = Get-CimInstance Win32_Volume -Filter "DriveLetter='$Drive'" -ErrorAction Stop
    if (-not $vol) {
        throw "Drive not found"
    }
} catch {
    Write-Host ""
    Write-Host "[ERROR] ERROR OCCURRED"
    Write-Host "=============================================================="
    Write-Host "  Target drive $Drive not found"
    Write-Host ""
    Write-Host "  Error: $($_.Exception.Message)"
    exit 1
}

# Check and start BDESVC
try {
    $svc = Get-Service -Name "BDESVC" -ErrorAction Stop
    if ($svc.Status -ne "Running") {
        Start-Service -Name "BDESVC" -ErrorAction Stop
    }
    Set-Service -Name "BDESVC" -StartupType Automatic -ErrorAction Stop
    $svcStatus = (Get-Service BDESVC).Status
    Write-Host "  BDESVC Status    : $svcStatus"
} catch {
    Write-Host ""
    Write-Host "[ERROR] ERROR OCCURRED"
    Write-Host "=============================================================="
    Write-Host "  BDESVC (BitLocker Service) check failed"
    Write-Host ""
    Write-Host "  Error: $($_.Exception.Message)"
    Write-Host ""
    Write-Host "  Troubleshooting:"
    Write-Host "  - Ensure BitLocker feature is installed"
    Write-Host "  - Check Windows edition supports BitLocker (Pro/Enterprise)"
    exit 1
}

# Check TPM status
$tpmPresent = $false
$tpmReady = $false
try {
    $tpm = Get-Tpm -ErrorAction Stop
    $tpmPresent = $tpm.TpmPresent
    $tpmReady = $tpm.TpmReady
} catch {
    # TPM not available
}
Write-Host "  TPM Present      : $tpmPresent"
Write-Host "  TPM Ready        : $tpmReady"

# Check current BitLocker state
$blv = $null
try {
    $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction Stop
    Write-Host "  Current Status   : $($blv.VolumeStatus)"
    Write-Host "  Protection       : $($blv.ProtectionStatus)"
} catch {
    Write-Host "  Current Status   : Not configured"
}

# ==============================================================================
# CONFIGURE PROTECTORS
# ==============================================================================

Write-Host ""
Write-Host "[RUN] CONFIGURE PROTECTORS"
Write-Host "=============================================================="

# Handle Recovery Password protector
$existingRecProtectors = @()
if ($blv -and $blv.KeyProtector) {
    $existingRecProtectors = @($blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' })
}

# Remove existing recovery protectors if Force is set
if ($Force -and $existingRecProtectors.Count -gt 0) {
    Write-Host "  Removing $($existingRecProtectors.Count) existing RecoveryPassword protector(s)"

    foreach ($kp in $existingRecProtectors) {
        try {
            Remove-BitLockerKeyProtector -MountPoint $Drive -KeyProtectorId $kp.KeyProtectorId -ErrorAction Stop | Out-Null
            Write-Host "  Removed old protector : $($kp.KeyProtectorId)"
        } catch {
            Write-Host "  Failed to remove     : $($kp.KeyProtectorId) - $($_.Exception.Message)"
            $errorOccurred = $true
        }
    }

    # Refresh volume state
    $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue
}

# Check if recovery protector exists after cleanup
$recProtectorExists = $false
if ($blv -and $blv.KeyProtector) {
    $recProtectorExists = ($blv.KeyProtector.KeyProtectorType -contains 'RecoveryPassword')
}

# Add new recovery protector if needed
if (-not $recProtectorExists) {
    try {
        Add-BitLockerKeyProtector -MountPoint $Drive -RecoveryPasswordProtector -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
        Write-Host "  Added new RecoveryPassword protector"

        $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction Stop
        $newKp = $blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1

        if ($newKp) {
            $recoveryKeyId = $newKp.KeyProtectorId
            $recoveryKey = Get-RecoveryPasswordForId -MountPoint $Drive -KeyProtectorId $recoveryKeyId
            Write-Host "  Recovery Key ID  : $recoveryKeyId"
        } else {
            Write-Host "  Failed to retrieve new protector ID"
            $errorOccurred = $true
        }
    } catch {
        Write-Host "  Failed to add RecoveryPassword protector: $($_.Exception.Message)"
        $errorOccurred = $true
    }
} else {
    $existingKp = $blv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
    $recoveryKeyId = $existingKp.KeyProtectorId
    $recoveryKey = Get-RecoveryPasswordForId -MountPoint $Drive -KeyProtectorId $recoveryKeyId
    Write-Host "  RecoveryPassword protector already exists"
    Write-Host "  Recovery Key ID  : $recoveryKeyId"
}

# Handle TPM protector
if ($tpmPresent -and $tpmReady) {
    $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue
    $hasTpm = $false

    if ($blv -and $blv.KeyProtector) {
        $hasTpm = ($blv.KeyProtector.KeyProtectorType -contains 'Tpm')
    }

    if (-not $hasTpm) {
        try {
            Add-BitLockerKeyProtector -MountPoint $Drive -TpmProtector -ErrorAction Stop -WarningAction SilentlyContinue | Out-Null
            Write-Host "  TPM protector added"
        } catch {
            Write-Host "  Failed to add TPM protector: $($_.Exception.Message)"
            $errorOccurred = $true
        }
    } else {
        Write-Host "  TPM protector already present"
    }
} else {
    Write-Host "  TPM not available - skipping TPM protector"
}

# ==============================================================================
# ENABLE ENCRYPTION
# ==============================================================================

Write-Host ""
Write-Host "[RUN] ENABLE ENCRYPTION"
Write-Host "=============================================================="

if (-not $errorOccurred) {
    $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue
    $needEnable = $true

    if ($blv -and ($blv.VolumeStatus -in @('EncryptionInProgress', 'EncryptionSuspended', 'FullyEncrypted'))) {
        $needEnable = $false
        Write-Host "  Encryption already active - skipping enable"
        Write-Host "  Volume Status    : $($blv.VolumeStatus)"
    }

    if ($needEnable) {
        # Use manage-bde for cleaner state transition when protectors exist
        if ($blv -and $blv.KeyProtector.Count -gt 0) {
            Write-Host "  Starting encryption with manage-bde..."

            $bdeArgs = @('-on', $Drive, '-skiphardwaretest')
            if ($UsedSpaceOnly) { $bdeArgs += '-usedspaceonly' }
            if ($EncryptionMethod) { $bdeArgs += @('-encryptionmethod', $EncryptionMethod) }

            try {
                & manage-bde @bdeArgs 2>&1 | Out-Null
                Write-Host "  Encryption initiated successfully"
            } catch {
                Write-Host "  manage-bde failed: $($_.Exception.Message)"
                $errorOccurred = $true
            }
        } else {
            # Fallback to Enable-BitLocker
            Write-Host "  Starting encryption with Enable-BitLocker..."
            try {
                Enable-BitLocker -MountPoint $Drive -EncryptionMethod $EncryptionMethod `
                    -UsedSpaceOnly:$UsedSpaceOnly -SkipHardwareTest:$SkipHardwareTest -ErrorAction Stop | Out-Null
                Write-Host "  Encryption initiated successfully"
            } catch {
                Write-Host "  Enable-BitLocker failed: $($_.Exception.Message)"
                $errorOccurred = $true
            }
        }

        # Show current status
        $blv = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue
        if ($blv) {
            Write-Host "  Volume Status    : $($blv.VolumeStatus)"
            Write-Host "  Encryption %     : $($blv.EncryptionPercentage)"
        }
    }
}

# ==============================================================================
# RECOVERY KEY
# ==============================================================================

Write-Host ""
Write-Host "[INFO] RECOVERY KEY"
Write-Host "=============================================================="

if ($errorOccurred) {
    Write-Host "  Configuration failed - recovery key may not be available"
} else {
    Write-Host "  Recovery Key ID  : $recoveryKeyId"

    if ([string]::IsNullOrWhiteSpace($recoveryKey)) {
        Write-Host ""
        Write-Host "  Recovery password not retrieved"
        Write-Host "  (May be blocked by policy or provider)"
    } else {
        Write-Host "  Recovery Password:"
        Write-Host "  $recoveryKey"
    }
}

# ==============================================================================
# SUPEROPS SYNC
# ==============================================================================

Write-Host ""
Write-Host "[RUN] SUPEROPS SYNC"
Write-Host "=============================================================="

$syncErrorOccurred = $false

# Send Recovery Key ID
if (-not [string]::IsNullOrWhiteSpace($recoveryKeyId)) {
    try {
        Send-CustomField -CustomFieldName "BitLocker Recovery Key ID" -Value $recoveryKeyId -ErrorAction Stop
        Write-Host "  Sent BitLocker Recovery Key ID to SuperOps"
    } catch {
        Write-Host "  Failed to send Recovery Key ID: $($_.Exception.Message)"
        $syncErrorOccurred = $true
    }
} else {
    Write-Host "  No Recovery Key ID to send"
}

# Send Recovery Password
if (-not [string]::IsNullOrWhiteSpace($recoveryKey)) {
    try {
        Send-CustomField -CustomFieldName "BitLocker Recovery Password" -Value $recoveryKey -ErrorAction Stop
        Write-Host "  Sent BitLocker Recovery Password to SuperOps"
    } catch {
        Write-Host "  Failed to send Recovery Password: $($_.Exception.Message)"
        $syncErrorOccurred = $true
    }
} else {
    Write-Host "  No Recovery Password to send"
}

if ($syncErrorOccurred) {
    Write-Host ""
    Write-Host "  Warning: Some custom fields failed to sync"
    Write-Host "  Verify fields exist in SuperOps tenant"
} else {
    Write-Host "  Custom fields synchronized"
}

# ==============================================================================
# FINAL STATUS
# ==============================================================================

if ($errorOccurred) {
    Write-Host ""
    Write-Host "[ERROR] FINAL STATUS"
    Write-Host "=============================================================="
    Write-Host "  Result           : FAILED"
    Write-Host "  BitLocker enablement encountered errors"
    exit 1
} else {
    $final = Get-BitLockerVolume -MountPoint $Drive -ErrorAction SilentlyContinue
    Write-Host ""
    Write-Host "[OK] FINAL STATUS"
    Write-Host "=============================================================="
    Write-Host "  Result           : SUCCESS"
    if ($final) {
        Write-Host "  Protection       : $($final.ProtectionStatus)"
        Write-Host "  Volume Status    : $($final.VolumeStatus)"
    }
    Write-Host "  BitLocker enablement completed"
}

Write-Host ""
Write-Host "[OK] SCRIPT COMPLETED"
Write-Host "=============================================================="

exit 0

What makes this script different

Key features we built in after getting burned by the online scripts:

  • TPM validation: checks TpmPresent AND TpmReady before touching anything
  • protector cleanup: removes duplicates before adding new ones
  • dual retrieval: Get-BitLockerVolume CIM method with manage-bde as a fallback
  • RMM sync: recovery key pushed to custom field automatically
  • XTS-AES-256: strongest encryption method Windows offers

Recovery keys are printed to RMM logs AND synced to custom fields. Even if script output is truncated by the RMM log size limit, the key is stored with the asset record. No spreadsheets, no sticky notes, no "where did we put that key when the user traveled?"

Outcome

Script deployed Friday at 6 PM. By Saturday morning, 119 machines were encrypted. 8 machines had TPM issues that traced back to out-of-date firmware — a quick BIOS update cleared them and the next run caught them up. Full compliance report was sent to the insurance auditor Sunday afternoon, 14 hours inside the deadline.

endpoints encrypted: 127 of 127 deployment time: 18 hours (overnight) technician time: 45 minutes total recovery keys escrowed: 100% in RMM

Key takeaways