This guide assumes Powershell 7+ on any platform. The SecretManagement framework 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 None writes 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-Credential in 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 -AsPlainText from 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-Secret calls 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:

  1. 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 call Get-Secret. Now there's exactly one place to grep for the value, and it's not in your source code.
  2. Set up a vault per environment, not one vault with prefixed names. dev, stage, prod as three separately-registered vaults makes "is this script targeting prod?" a structural question instead of a string-matching one.
  3. 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).