In this guide, we’ll explore how you can leverage Azure Key Vault to securely manage secrets in your single-tenant or multi-tenant PowerShell scripts. This will enable you to securely access authentication tokens and other sensitive information needed to interact with downstream customer environments. Let’s dive in!
Understanding the Secure Application Model
Many of us are familiar with the secure application model, even if we don’t realize it. Tools like CIPP leverage this architecture to obtain access tokens for downstream customer environments through established GDAP relationships. For example, I wrote a multi-tenant script that loops through all my customers in partner center and inspects user mailboxes for inbox rules, specifically those related to forwarding, as a part of regular security audits.
I have written multiple blog post on how to set up the secure application model:
The Importance of Securely Managing Secrets
When running scripts that require sensitive information such as app registration details or refresh tokens, it’s crucial to manage these secrets securely. Azure Key Vault is an excellent solution for this. It allows you to store and access these sensitive artifacts securely, avoiding the risks associated with storing them locally or manually copying and pasting them into your scripts.
Doing so also allows you to set up headless automation so you can run these scripts on a schedule behind the scenes as well.
Setting Up Azure Key Vault
While this can certainly be done manually, I made a script for the Secure Application model where you can easily create and load your secrets into a Key Vault:
#PARAMETERS param ( [Parameter(Mandatory=$false)] [string] $AppId, [Parameter(Mandatory=$false)] [string] $AppSecret, [Parameter(Mandatory=$false)] [string] $TenantId, [Parameter(Mandatory=$false)] [string] $refreshToken, [Parameter(Mandatory=$false)] [string] $PartnerRefreshToken, [Parameter(Mandatory=$false)] [string] $AppDisplayName ) $ErrorActionPreference = "SilentlyContinue" #See if Az.Accounts and Az.KeyVault Powershell module is installed and if not install it Write-Host "Checking for Az module" $AzModule = Get-Module -Name Az.Accounts -ListAvailable if ($null -eq $AzModule) { Write-Host "Az module not found, installing now" Install-Module -Name Az.Accounts -Force -AllowClobber } else { Write-Host "Az module found" } $AzModule = Get-Module -Name Az.KeyVault -ListAvailable if ($null -eq $AzModule) { Write-Host "Az.KeyVault module not found, installing now" Install-Module -Name Az.KeyVault -Force -AllowClobber } else { Write-Host "Az.KeyVault module found" } #Connect to Azure, sign in with Global Admin Credentials Connect-AzAccount #Set values for Key Vault Name and Resource Group and Location $KeyVaultName = Read-Host -Prompt "Enter the name you want the key vault to be called. Make this unique" $ResourceGroupName = Read-Host -Prompt "Enter the name of the resource group you want the key vault to be in" $Location = Read-Host -Prompt "Enter the location you want the key vault to be in, i.e. East US" #Create the Key Vault try { $KeyVault = New-AzKeyVault -VaultName $KeyVaultName -ResourceGroupName $ResourceGroupName -Location $Location Write-Host "Key Vault created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } #Create the secrets based on the params in the key vault #AppID try{ $secretvalue = ConvertTo-SecureString $AppId -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppID" -SecretValue $secretvalue Write-Host "AppID secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create AppID secret" -ForegroundColor Red Write-Host $_.Exception.Message return } #AppSecret try{ $secretvalue = ConvertTo-SecureString $AppSecret -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppSecret" -SecretValue $secretvalue Write-Host "AppSecret secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create AppSecret secret" -ForegroundColor Red Write-Host $_.Exception.Message return } #TenantID try{ $secretvalue = ConvertTo-SecureString $TenantId -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "TenantID" -SecretValue $secretvalue Write-Host "TenantID secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create TenantID secret" -ForegroundColor Red Write-Host $_.Exception.Message return } #RefreshToken try{ $secretvalue = ConvertTo-SecureString $refreshToken -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "RefreshToken" -SecretValue $secretvalue Write-Host "RefreshToken secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create RefreshToken secret" -ForegroundColor Red Write-Host $_.Exception.Message return } #PartnerRefreshToken try{ $secretvalue = ConvertTo-SecureString $PartnerRefreshToken -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "PartnerRefreshToken" -SecretValue $secretvalue Write-Host "PartnerRefreshToken secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create PartnerRefreshToken secret" -ForegroundColor Red Write-Host $_.Exception.Message return } #AppDisplayName try{ $secretvalue = ConvertTo-SecureString $AppDisplayName -AsPlainText -Force $secret = Set-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppDisplayName" -SecretValue $secretvalue Write-Host "AppDisplayName secret created successfully" -ForegroundColor Green } catch { Write-Host "Failed to create AppDisplayName secret" -ForegroundColor Red Write-Host $_.Exception.Message return }
Ensure your key vault name is unique globally. Also ensure it is not too many characters, it needs to be 32 characters or less. Result of your actions:
Accessing Secrets in PowerShell Scripts
Once your secrets are stored in Azure Key Vault, you can access them securely in your PowerShell scripts. Here’s how you can modify your script to retrieve these secrets:
#PARAMETERS param ( [Parameter(Mandatory=$false)] [string] $KeyVaultName ) $ErrorActionPreference = "SilentlyContinue" #See if Az.Accounts and Az.KeyVault Powershell module is installed and if not install it Write-Host "Checking for Az module" $AzModule = Get-Module -Name Az.Accounts -ListAvailable if ($null -eq $AzModule) { Write-Host "Az module not found, installing now" Install-Module -Name Az.Accounts -Force -AllowClobber } else { Write-Host "Az module found" } $AzModule = Get-Module -Name Az.KeyVault -ListAvailable if ($null -eq $AzModule) { Write-Host "Az.KeyVault module not found, installing now" Install-Module -Name Az.KeyVault -Force -AllowClobber } else { Write-Host "Az.KeyVault module found" } #Connect to Azure, sign in with Global Admin Credentials try{ Connect-AzAccount Write-Host "Connected to Azure" -ForegroundColor Green } catch { Write-Host "Failed to connect to Azure" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $AppId = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppID" -AsPlainText Write-Host "Got AppID from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get AppID from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $AppSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppSecret" -AsPlainText Write-Host "Got AppSecret from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get AppSecret from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $TenantId = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "TenantId" -AsPlainText Write-Host "Got TenantId from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get TenantId from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $refreshToken = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "RefreshToken" -AsPlainText Write-Host "Got RefreshToken from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get RefreshToken from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break }
This script will prompt you for your Azure credentials and authenticate securely using the stored secrets. Example of this in a script:
Running Your Secure PowerShell Script
From here, we can just use these variables to get access tokens to run more multi-tenant scripts. Example functions:
function Get-GraphAccessToken ($TenantId) { $Body = @{ 'tenant' = $TenantId 'client_id' = $AppId 'scope' = 'https://graph.microsoft.com/.default' 'client_secret' = $AppSecret 'grant_type' = 'refresh_token' 'refresh_token' = $refreshToken } $Params = @{ 'Uri' = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" 'Method' = 'Post' 'ContentType' = 'application/x-www-form-urlencoded' 'Body' = $Body } $AuthResponse = Invoke-RestMethod @Params Write-Host "Got accestoken for $AppID" return $AuthResponse.access_token } function Get-AppPermAccessToken ($TenantId) { $Body = @{ 'tenant' = $TenantId 'client_id' = $AppId 'scope' = 'https://graph.microsoft.com/.default' 'client_secret' = $AppSecret 'grant_type' = 'client_credentials' } $Params = @{ 'Uri' = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" 'Method' = 'Post' 'ContentType' = 'application/x-www-form-urlencoded' 'Body' = $Body } $AuthResponse = Invoke-RestMethod @Params Write-Host "Got accestoken for $AppID" return $AuthResponse.access_token }
Full Multi-tenant Inbox Rule Script:
#PARAMETERS param ( [Parameter(Mandatory=$false)] [string] $KeyVaultName ) $ErrorActionPreference = "SilentlyContinue" #See if Az.Accounts and Az.KeyVault Powershell module is installed and if not install it Write-Host "Checking for Az module" $AzModule = Get-Module -Name Az.Accounts -ListAvailable if ($null -eq $AzModule) { Write-Host "Az module not found, installing now" Install-Module -Name Az.Accounts -Force -AllowClobber } else { Write-Host "Az module found" } $AzModule = Get-Module -Name Az.KeyVault -ListAvailable if ($null -eq $AzModule) { Write-Host "Az.KeyVault module not found, installing now" Install-Module -Name Az.KeyVault -Force -AllowClobber } else { Write-Host "Az.KeyVault module found" } #Connect to Azure, sign in with Global Admin Credentials try{ Connect-AzAccount Write-Host "Connected to Azure" -ForegroundColor Green } catch { Write-Host "Failed to connect to Azure" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $AppId = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppID" -AsPlainText Write-Host "Got AppID from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get AppID from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $AppSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "AppSecret" -AsPlainText Write-Host "Got AppSecret from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get AppSecret from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $TenantId = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "TenantId" -AsPlainText Write-Host "Got TenantId from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get TenantId from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } try { $refreshToken = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name "RefreshToken" -AsPlainText Write-Host "Got RefreshToken from Key Vault" -ForegroundColor Green } catch { Write-Host "Failed to get RefreshToken from Key Vault" -ForegroundColor Red Write-Host $_.Exception.Message break } # Function to get an access token for Microsoft Graph function Get-GraphAccessToken ($TenantId) { $Body = @{ 'tenant' = $TenantId 'client_id' = $AppId 'scope' = 'https://graph.microsoft.com/.default' 'client_secret' = $AppSecret 'grant_type' = 'refresh_token' 'refresh_token' = $refreshToken } $Params = @{ 'Uri' = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" 'Method' = 'Post' 'ContentType' = 'application/x-www-form-urlencoded' 'Body' = $Body } $AuthResponse = Invoke-RestMethod @Params Write-Host "Got accestoken for $AppID" return $AuthResponse.access_token } function Get-AppPermAccessToken ($TenantId) { $Body = @{ 'tenant' = $TenantId 'client_id' = $AppId 'scope' = 'https://graph.microsoft.com/.default' 'client_secret' = $AppSecret 'grant_type' = 'client_credentials' } $Params = @{ 'Uri' = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" 'Method' = 'Post' 'ContentType' = 'application/x-www-form-urlencoded' 'Body' = $Body } $AuthResponse = Invoke-RestMethod @Params Write-Host "Got accestoken for $AppID" return $AuthResponse.access_token } # Initialize a list to hold audit logs $AuditLogs = [System.Collections.Generic.List[Object]]::new() #function to record an audit log of events function Write-Log { param ( [string]$customerName, [string]$EventName, [string]$Status, [string]$Message ) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $Result = @{ 'Customer Name' = $CustomerName 'Timestamp' = $timestamp 'Event' = $EventName 'Status' = $Status 'Message' = $Message } $ReportLine = New-Object PSObject -Property $Result $AuditLogs.Add($ReportLine) # Optionally, you can also display this log message in the console Write-Host $logMessage } # Initialize a list to hold the results $Report = [System.Collections.Generic.List[Object]]::new() # Function for adding output to the list Function ExportCSV { Param($MailBoxName, $UPN, $InboxRule, $CustomerName) if ($InboxRule.actions.ForwardTo.Count -gt 0) { # Collect email addresses into an array $emailAddresses = $InboxRule.actions.ForwardTo | ForEach-Object { $_.emailaddress.address } # Join email addresses with a comma $ForwardTo = $emailAddresses -join ',' } else { $ForwardTo = $null } if ($InboxRule.actions.redirectTo.Count -gt 0) { #loop through the fw to addresses and join them with a comma $redirects = $InboxRule.actions.RedirectTo | ForEach-Object { $_.emailaddress.address } $RedirectTo = $redirects -join ',' } else { $RedirectTo = $null } if ($InboxRule.actions.forwardAsAttachmentTo.Count -gt 0) { #loop through the fw to addresses and join them with a comma $forwardAsAttachmentTo = $InboxRule.actions.forwardAsAttachmentTo | ForEach-Object { $_.emailaddress.address } $forwardAsAttachmentTo = $forwardAsAttachmentTo -join ',' } else { $forwardAsAttachmentTo = $null } If ($InboxRule.actions.MoveToFolder -ne $null) { $MoveToFolder = $true } else { $MoveToFolder = $null } $Result = @{ 'Customer Name' = $CustomerName 'Mailbox Name' = $MailBoxName 'UPN' = $UPN 'Inbox Rule Name' = $InboxRule.displayName 'Enabled' = $InboxRule.isEnabled 'Forward To' = $ForwardTo 'Redirect To' = $RedirectTo 'Forward As Attachment To' = $forwardAsAttachmentTo 'Move To Folder' = $MoveToFolder 'Delete Message' = $InboxRule.actions.delete 'Mark As Read' = $InboxRule.actions.markAsRead } $ReportLine = New-Object PSObject -Property $Result $Report.Add($ReportLine) } # Get the access token try { $AccessToken = Get-GraphAccessToken -TenantId $TenantId Write-Host "Got partner access token for Microsoft Graph" -ForegroundColor Green Write-Log -customerName "Partner Tenant" -EventName "Get-GraphAccessToken" -Status "Success" -Message "Got partner access token for Microsoft Graph" } catch { Write-Host "Failed to partner access token for Microsoft Graph" -ForegroundColor Red $ErrorMessage = $_.Exception.Message Write-Log -customerName "Partner Tenant" -EventName "Get-GraphAccessToken" -Status "Fail" -Message $ErrorMessage break } # Define header with authorization token $Headers = @{ 'Authorization' = "Bearer $AccessToken" 'Content-Type' = 'application/json' } # Get GDAP Customers try{ $GraphUrl = "https://graph.microsoft.com/v1.0/tenantRelationships/delegatedAdminCustomers" $Customers = Invoke-RestMethod -Uri $GraphUrl -Headers $Headers -Method Get #Write-Host a list of the customer display Names Write-Host "Customers:" -ForegroundColor Green $Customers.value.displayName Write-Log -customerName "Partner Tenant" -EventName "Get-GDAPCustomers" -Status "Success" -Message "Got GDAP Customers" }catch{ Write-Host "Failed to get GDAP Customers" -ForegroundColor Red $ErrorMessage = $_.Exception.Message Write-Log -customerName "Partner Tenant" -EventName "Get-GDAPCustomers" -Status "Fail" -Message $ErrorMessage break } #Loop Through Each Customer, Connect to Exchange Online and Get Mailboxes $Customers.value | ForEach-Object { $Customer = $_ $CustomerName = $Customer.displayName $CustomerTenantId = $Customer.id # Display the customer name Write-Host "Processing Customer: $CustomerName" -ForegroundColor Green # Get the access token for the customer with Refresh token try { Write-Host "Getting Graph Token" -ForegroundColor Green $GraphAccessToken = Get-GraphAccessToken -TenantId $CustomerTenantId Write-Log -customerName $CustomerName -EventName "Get Graph Token for customer" -Status "Success" -Message "Got access token for Microsoft Graph" } catch { $ErrorMSg = $_.Exception.Message Write-Host "Failed to get Graph Token for $CustomerName - $ErrorMsg" Write-Log -customerName $CustomerName -EventName "Get Graph Token for customer" -Status "Fail" -Message $_.Exception.Message return } # Get the access token for the customer with client credentials try { Write-Host "Getting Application Permission Graph Token" -ForegroundColor Green $AppPermAccessToken = Get-AppPermAccessToken -TenantId $CustomerTenantId Write-Log -customerName $CustomerName -EventName "Get Application Permission Graph Token for customer" -Status "Success" -Message "Got access token for Microsoft Graph" } catch { $ErrorMSg = $_.Exception.Message Write-Host "Failed to get Application Permission Graph Token for $CustomerName - $ErrorMsg" Write-Log -customerName $CustomerName -EventName "Get Application Permission Graph Token for customer" -Status "Fail" -Message $_.Exception.Message return } try{ # Use the graph access token to call the graph API to get all mailboxes $GraphUrl = "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')?`$format=application/json" $Headers = @{ 'Authorization' = "Bearer $GraphAccessToken" 'Content-Type' = 'application/json' } $Mailboxes = Invoke-RestMethod -Uri $GraphUrl -Headers $Headers -Method Get Write-Log -customerName $CustomerName -EventName "Get Mailboxes" -Status "Success" -Message "Retrieved mailboxes" } catch { $ErrorMSg = $_.Exception.Message Write-Host "Failed to get mailboxes for $CustomerName - $ErrorMsg" -ForegroundColor Red Write-Log -customerName $CustomerName -EventName "Get Mailboxes" -Status "Fail" -Message $_.Exception.Message return } #Reset headers for the application permission token $Headers = @{ 'Authorization' = "Bearer $AppPermAccessToken" 'Content-Type' = 'application/json' } #loop thright the mailboxes to get the inbox rules $Mailboxes.value | ForEach-Object { Write-Host "Processing Mailbox: $($_.displayName)" $MailBoxName = $_.DisplayName $UPN = $_.UserPrincipalName try{ $GraphInboxRuleURL = "https://graph.microsoft.com/v1.0/users/$UPN/mailFolders/inbox/messageRules" $InboxRules = (Invoke-RestMethod -Uri $GraphInboxRuleURL -Headers $Headers -Method Get).value #if there are any inbox rules, loop through them and export them to CSV if ($InboxRules.Count -gt 0) { $InboxRules | ForEach-Object { Write-Host "Exporting Inbox Rule: $($_.displayName) for $UPN" -ForegroundColor Green ExportCSV -MailBoxName $MailBoxName -UPN $UPN -InboxRule $_ -CustomerName $CustomerName } Write-Log -customerName $CustomerName -EventName "Get Inbox Rules-$UPN" -Status "Success" -Message "Retrieved inbox rules" } else{ Write-Log -customerName $CustomerName -EventName "Get Inbox Rules-$UPN" -Status "Success" -Message "No Inbox rules for $UPN" } } catch { $ErrorMSg = $_.Exception.Message Write-Host "Failed to get inbox rules for $UPN - $ErrorMsg" -ForegroundColor Red Write-Log -customerName $CustomerName -EventName "Get Inbox Rules-$UPN" -Status "Fail" -Message $_.Exception.Message return } } } # When displaying and exporting, select the properties in the desired order $Report | Select-Object 'Customer Name', 'Mailbox Name', 'UPN', 'Inbox Rule Name', 'Enabled', 'Forward To', 'Redirect To', 'Forward As Attachment To', 'Move To Folder', 'Delete Message', 'Mark As Read' | Out-GridView $path = "$((Get-Location).Path)\Inbox Rules $(Get-Date -Format 'yyyy-MM-dd').csv" $Report | Select-Object 'Customer Name', 'Mailbox Name', 'UPN', 'Inbox Rule Name', 'Enabled', 'Forward To', 'Redirect To', 'Forward As Attachment To', 'Move To Folder', 'Delete Message', 'Mark As Read' | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 $AuditLogPath = "$((Get-Location).Path)\Inbox Rules $(Get-Date -Format 'yyyy-MM-dd') Audit Log.csv" $AuditLogs | Select-Object 'Customer Name', 'Timestamp', 'Event', 'Status', 'Message' | Export-CSV -Path $AuditLogPath -NoTypeInformation Write-Host "Report saved to $Path" -ForegroundColor Cyan
Conclusion
Using Azure Key Vault in your PowerShell scripts not only enhances security by securely managing sensitive information but also streamlines your workflow by automating the retrieval of secrets. This ensures that your scripts run smoothly and securely, without the need for manual input of sensitive data.
I hope this guide helps you understand how to leverage Azure Key Vault for secure scripting. If you want me to help with this in a consulting capacity, check out my premium content page and fill out a consulting request. Happy scripting!