This guide assumes Powershell 7+ on any platform. The
SecretManagementframework works on 5.1 too, but the cross-platform vault story is meaningfully better on 7.
The single most common Powershell security finding in any audit is "hardcoded API token in script". The second is "token in a .env file committed to git". The third is "token in $env:TOKEN set by a wrapper that nobody can find". Microsoft.PowerShell.SecretManagement is Microsoft's answer a single API on top of pluggable vault backends.
The Two Modules
Install-Module Microsoft.PowerShell.SecretManagement -Scope CurrentUser
Install-Module Microsoft.PowerShell.SecretStore -Scope CurrentUser
SecretManagement is the framework the cmdlets you'll call (Get-Secret, Set-Secret, Remove-Secret). SecretStore is one implementation a local encrypted vault. Other vault modules plug into the same API.
Setting Up the Local Vault
$vaultParams = @{
Name = 'LocalStore'
ModuleName = 'Microsoft.PowerShell.SecretStore'
DefaultVault = $true
}
Register-SecretVault @vaultParams
$storeConfig = @{
Authentication = 'Password'
PasswordTimeout = 600
Interaction = 'Prompt'
}
Set-SecretStoreConfiguration @storeConfig
First call to Get-Secret will prompt for the vault master password. After that, secrets are unlocked for the configured timeout. For unattended use, configure with -Authentication None (less secure but works for headless service accounts):
Set-SecretStoreConfiguration -Authentication None -Interaction None -Confirm:$false
-Authentication Nonewrites the master password to disk in a file readable by the current user. Use it only when the OS-level account isolation is your security boundary anyway (CI runners, dedicated service accounts).
Storing and Retrieving
Set-Secret -Name 'GitlabToken' -Secret 'glpat-xxxxxxxxxxxxxxxxxxxx'
Get-Secret -Name 'GitlabToken' -AsPlainText # returns string
Get-Secret -Name 'GitlabToken' # returns SecureString (default)
Get-SecretInfo # list everything in all vaults
For credentials (username + password):
$cred = Get-Credential
Set-Secret -Name 'GitlabApi' -Secret $cred
$cred = Get-Secret -Name 'GitlabApi' # PSCredential
Invoke-RestMethod -Uri $url -Authentication Basic -Credential $cred
Set-Secret accepts strings, SecureString, PSCredential, byte[], and Hashtable. Pick whatever fits the vault stores them all natively.
Multiple Vaults Side by Side
The point of the framework is that you can register several backends and address them by name:
$kvParams = @{
Name = 'keyvault'
ModuleName = 'Az.KeyVault.Extension'
VaultParameters = @{ AZKVaultName = 'kv-prod' }
}
Register-SecretVault @kvParams
$opwParams = @{
Name = 'onepw'
ModuleName = 'SecretManagement.1Password'
VaultParameters = @{ AccountName = 'team'; Vault = 'Operations' }
}
Register-SecretVault @opwParams
$localParams = @{
Name = 'local'
ModuleName = 'Microsoft.PowerShell.SecretStore'
}
Register-SecretVault @localParams
Get-Secret -Name 'GitlabToken' -Vault local
Get-Secret -Name 'sql-prod-readonly' -Vault keyvault
Get-Secret -Name 'pagerduty' -Vault onepw
A common pattern: local vault for personal/dev secrets, KeyVault for production automation, 1Password (or Bitwarden, or LastPass there's a module for each) for human-managed shared secrets.
Azure Key Vault Backend
Install-Module Az.KeyVault, Microsoft.PowerShell.SecretManagement
Connect-AzAccount # interactive once
$kvParams = @{
Name = 'keyvault'
ModuleName = 'Az.KeyVault.Extension'
VaultParameters = @{ AZKVaultName = 'kv-prod'; SubscriptionId = $sub }
}
Register-SecretVault @kvParams
For unattended automation, use a service principal with cert-based auth (the same pattern from the Graph API post):
$azConnect = @{
ServicePrincipal = $true
Tenant = $env:AZ_TENANT
ApplicationId = $env:AZ_CLIENT
CertificateThumbprint = $env:AZ_CERT
}
Connect-AzAccount @azConnect
Now Get-Secret -Vault keyvault -Name 'token' pulls live from KeyVault no token ever lands on disk.
Rotation
The whole point of secret management is making rotation cheap. With SecretManagement, rotating a token is two calls plus updating consumers:
$new = New-GitlabToken -Description 'rotated-2026-01' -Scope api
Set-Secret -Name 'GitlabToken' -Secret $new.token
Remove-GitlabToken -Id $oldTokenId
Consumers don't change because they call Get-Secret -Name 'GitlabToken' they always get the current value.
Schedule rotation, don't react to leaks. A 90-day rotation cadence on every credential turns "leaked token" from a P1 into a "we just rotated that anyway." Build the rotation script once per token type, then put it on a schedule.
Calling Code Pattern
Every script that needs a secret should look like this:
$token = Get-Secret -Name 'GitlabToken' -AsPlainText
try
{
Invoke-RestMethod -Uri $url -Headers @{ 'PRIVATE-TOKEN' = $token }
} finally {
Remove-Variable token
}
The Remove-Variable token is paranoia; the GC will reclaim it eventually. For genuinely sensitive memory, work with SecureString end-to-end and convert at the call site:
$secure = Get-Secret -Name 'GitlabToken' # SecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
try
{
$plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)
Invoke-RestMethod -Uri $url -Headers @{ 'PRIVATE-TOKEN' = $plain }
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
Worth it for the most sensitive credentials, overkill for most.
CI Integration
In CI, the vault is usually KeyVault (or your CI provider's secret store with KeyVault behind it). The pattern:
# GitHub Actions
- name: Run tools
shell: pwsh
env:
AZ_TENANT: ${{ secrets.AZ_TENANT }}
AZ_CLIENT: ${{ secrets.AZ_CLIENT }}
AZ_CERT: ${{ secrets.AZ_CERT_THUMBPRINT }}
run: |
Connect-AzAccount -ServicePrincipal -Tenant $env:AZ_TENANT -ApplicationId $env:AZ_CLIENT -CertificateThumbprint $env:AZ_CERT
Register-SecretVault -Name kv -ModuleName Az.KeyVault.Extension -VaultParameters @{ AZKVaultName = 'kv-ci' }
./build/Run.ps1
Run.ps1 calls Get-Secret -Vault kv -Name '...' for each credential it needs. The CI secrets store only holds one secret the cert that lets you get every other secret.
Multi-Environment Discipline
Use a vault per environment, not one vault with prefixed names:
# Bad
Get-Secret -Name 'prod-sql-pw'
Get-Secret -Name 'dev-sql-pw'
# Good
Get-Secret -Name 'sql-pw' -Vault keyvault-prod
Get-Secret -Name 'sql-pw' -Vault keyvault-dev
Why: the same script with the same Get-Secret calls runs unchanged in dev and prod, just with a different vault registration. No naming convention to enforce, no risk of pulling prod credentials into a dev script.
What Not To Do
- Don't
Get-Credentialin scripts. Either prompt at the entry point or read from a vault. Mid-script prompts break automation. - Don't write secrets to disk even temporarily. Keep them in memory.
- Don't
ConvertTo-SecureString -AsPlainTextfrom a hardcoded string. That's a hardcoded secret with extra steps. Read from the vault. - Don't share a single vault across teams. Vault permissions are your only audit trail of "who can see what". Share by issuing scoped credentials, not by sharing vault access.
- Don't put
Get-Secretcalls inside loops. Cache once at the top of a function, reuse. Some vaults (especially KeyVault) charge per call.
What to Do Next
SecretManagement is the API; the vault is the implementation; rotation is the practice. Register the right vault for the environment, fetch on demand, never write secrets to disk, schedule rotation rather than react to leaks.
Three concrete moves this week:
- Pick the script with the worst secret hygiene in your repo. The one with the API token literally pasted into a
$Token = '...'line. Rewrite it tonight to callGet-Secret. Now there's exactly one place to grep for the value, and it's not in your source code. - Set up a vault per environment, not one vault with prefixed names.
dev,stage,prodas three separately-registered vaults makes "is this script targeting prod?" a structural question instead of a string-matching one. - Schedule the first rotation. Pick the longest-lived credential in your environment, run the rotation script once manually, then put it on a 90-day schedule. After two cycles the team trusts the rotation; after four it stops being newsworthy.
Pairs naturally with the Graph API post (the certificate thumbprint a Graph script uses lives in this vault) and the SQL post (every connection string you stored as plaintext should be a Get-Secret call instead).


