01 The Problem With Hardcoded Credentials
Hardcoding passwords into PowerShell scripts is the single most common security mistake in enterprise automation. It happens not because admins are careless, but because the friction-free path — just typing the password directly — always feels faster in the moment.
# The classic disaster waiting to happen $username = "svc-deploy" $password = "P@ssw0rd123!" # 👈 Committed to Git in 3... 2... 1... $securePass = ConvertTo-SecureString $password -AsPlainText -Force $cred = New-Object System.Management.Automation.PSCredential($username, $securePass)
Real Risk
GitHub's secret scanning catches thousands of hardcoded credentials pushed by developers every week — many from PowerShell scripts. Once a credential is in Git history, it must be treated as fully compromised, even after deletion.
The alternative isn't complicated — it just requires knowing which tools exist. PowerShell ships with, or can install, everything you need to handle secrets safely. Let's walk through all of it.
02 The SecretManagement Module
Microsoft's SecretManagement module is the official answer: a unified API that works across multiple secret stores (local vault, Azure Key Vault, HashiCorp Vault, 1Password, and more) using the same four commands regardless of backend.
Installation
# Install the management layer and the local vault extension Install-Module -Name Microsoft.PowerShell.SecretManagement -Scope CurrentUser Install-Module -Name Microsoft.PowerShell.SecretStore -Scope CurrentUser # Register the local vault as your default Register-SecretVault -Name "LocalVault" -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
On first use, SecretStore will ask you to set a master password. This password encrypts a local file at ~/.secretmanagement/. You can configure whether it prompts on each session or caches for a duration.
The Four Commands You Need
| Command | What It Does | Works With |
|---|---|---|
Set-Secret |
Store a secret by name | Strings, SecureStrings, PSCredentials, byte arrays, hashtables |
Get-Secret |
Retrieve a secret | Returns SecureString by default; use -AsPlainText to get a raw string |
Remove-Secret |
Delete a secret by name | Any registered vault |
Get-SecretInfo |
List secrets without revealing values | Filter by vault, name pattern |
Storing and Retrieving Your First Secret
# Store a plain string (like an API key) Set-Secret -Name "GitHubToken" -Secret "ghp_xxxxxxxxxxxxxxxxxxxxx" # Store a PSCredential (username + password together) $cred = Get-Credential -Message "Enter service account credentials" Set-Secret -Name "SvcDeployCred" -Secret $cred # ───────────────────────────────────────────────────── # Retrieve and use — no plaintext ever in your script $token = Get-Secret -Name "GitHubToken" -AsPlainText $cred = Get-Secret -Name "SvcDeployCred" # returns PSCredential Invoke-RestMethod -Uri "https://api.github.com/user" ` -Headers @{ Authorization = "token $token" }
03 Working With PSCredential Objects
Even when you're not using a vault, PSCredential is the right container for username/password pairs in PowerShell. Most cmdlets that accept credentials — Invoke-Command, Connect-MsolService, New-PSSession, etc. — expect exactly this type.
# Option 1: Interactive prompt (best for development/debugging) $cred = Get-Credential # Option 2: From SecretManagement vault (best for automation) $cred = Get-Secret -Name "SvcDeployCred" # Option 3: Build manually from a stored SecureString $securePass = Get-Secret -Name "SvcDeployPassword" # SecureString $cred = New-Object System.Management.Automation.PSCredential( "DOMAIN\svc-deploy", $securePass ) # Using the credential Invoke-Command -ComputerName "PROD-SERVER-01" -Credential $cred -ScriptBlock { Get-Service -Name "Spooler" } # Extracting the password back out (when you must) $plainText = $cred.GetNetworkCredential().Password
Note on SecureString
SecureString is not encryption in transit or at rest — it's an in-memory protection against casual string inspection. It's still valuable (prevents password appearing in crash dumps, memory inspectors, and Write-Host accidents), but don't treat it as a security boundary on its own.
04 Environment Variables as a Pipeline-Friendly Option
For CI/CD pipelines (GitHub Actions, Azure DevOps, Jenkins), environment variables are the standard way to inject secrets at runtime. PowerShell reads them cleanly via $env:VARIABLE_NAME.
# Read a secret set by your CI/CD system $apiKey = $env:DEPLOY_API_KEY $dbPass = $env:DATABASE_PASSWORD # Always validate — fail loudly rather than silently using empty strings if (-not $env:DEPLOY_API_KEY) { throw "Required secret DEPLOY_API_KEY is not set. Aborting." } # Convert to SecureString if a cmdlet requires it $securePw = ConvertTo-SecureString $env:DATABASE_PASSWORD -AsPlainText -Force
The goal isn't to make secrets impossible to access — it's to ensure they never appear in source code, logs, or console output by default. Every layer you add between a secret and plain sight is a layer of protection.
05 Connecting to External Vaults
The SecretManagement module's real power is its extensibility. If your organization uses a dedicated secrets platform, there's almost certainly a vault extension for it.
| Vault | Extension Module | Best For | Cost |
|---|---|---|---|
| SecretStore (local) | Microsoft.PowerShell.SecretStore |
Personal scripts, dev machines | Free |
| Azure Key Vault | Az.KeyVault |
Azure-hosted workloads, teams | Paid |
| HashiCorp Vault | SecretManagement.Hashicorp.Vault.KV |
On-prem enterprise, multi-cloud | OSS |
| 1Password | SecretManagement.1Password |
Individuals and small teams | Paid |
| KeePass | SecretManagement.KeePass |
Air-gapped / offline environments | Free |
Example: Registering Azure Key Vault
# Authenticate to Azure first Connect-AzAccount # Register the Key Vault as a SecretManagement vault Register-SecretVault -Name "AzureKV" ` -ModuleName Az.KeyVault ` -VaultParameters @{ AZKVaultName = "myorg-prod-keyvault" SubscriptionId = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" } # From this point on, usage is identical to local vault $dbConn = Get-Secret -Name "prod-db-connection-string" -Vault "AzureKV" -AsPlainText
06 Handling Secrets in Unattended Scripts
The trickiest scenario: a fully automated script that runs on a schedule with no human in the loop. You can't prompt for a password. You can't rely on cached sessions. Here's the pattern that works.
# Configure SecretStore to not require interactive unlock # (uses a stored password — set this up once manually) Set-SecretStoreConfiguration -Authentication Password -Interaction None # Unlock the vault at script start using a machine-level credential # (the vault password itself is stored securely in DPAPI / OS keychain) $vaultPass = ConvertTo-SecureString $env:VAULT_UNLOCK_PASSWORD -AsPlainText -Force Unlock-SecretStore -Password $vaultPass # All subsequent Get-Secret calls work without prompting $apiCred = Get-Secret -Name "ApiServiceAccount" # ── Or skip SecretStore entirely and use a managed identity ── # On Azure VMs / App Services, no password needed at all: Connect-AzAccount -Identity # System-assigned managed identity $secret = Get-AzKeyVaultSecret -VaultName "myorg-prod-keyvault" -Name "ApiKey" -AsPlainText
Best Practice for Azure Automation
Use Managed Identities whenever possible. They eliminate the secret-bootstrapping problem entirely — the Azure platform handles authentication, no passwords stored anywhere.
07 Secrets Hygiene Checklist
Before shipping any script that touches credentials, run through this list:
- No plaintext passwords or API keys anywhere in the
.ps1file - Credentials are retrieved at runtime via SecretManagement, env vars, or managed identity
-AsPlainTextis used only where strictly necessary (REST calls, legacy APIs)- Scripts validate that required secrets exist before proceeding — no silent failures
- Secrets are never written to log files,
Write-Host, or transcript output - Rotation doesn't require touching script code — only vault contents
.gitignoreexcludes any exported credential or config files- CI/CD pipeline secrets are scoped to the minimum required environments