PowerShell Secure Coding: Avoid Common Security Mistakes

PowerShell scripts running with administrator privileges are some of the most powerful code in your environment. A single security mistake — a hard-coded password, an unchecked Invoke-Expression, an unsigned script — can become an attack vector. Following PowerShell security best practices from the start prevents the vulnerabilities that attackers specifically target in enterprise PowerShell automation.
Quick Answer / TL;DR
Never hard-code credentials. Avoid Invoke-Expression on user-controlled input. Sign scripts for AllSigned environments. Use Constrained Language Mode and principle of least privilege for all automation accounts.
Never Hard-Code Credentials
Plain-text passwords in scripts get committed to version control, logged in transcript files, and visible to anyone who reads the file. Use Get-Credential for interactive scripts, environment variables for CI/CD, and a secrets vault (Azure Key Vault, CyberArk, Windows Credential Manager) for scheduled automation.
# WRONG — never do this
$password = 'P@ssw0rd!'
$cred = New-Object PSCredential('CONTOSO\svc_auto', (ConvertTo-SecureString $password -AsPlainText -Force))
# CORRECT — retrieve from environment variable (set by CI/CD pipeline)
$securePass = $env:SERVICE_PASSWORD | ConvertTo-SecureString -AsPlainText -Force
$cred = New-Object PSCredential('CONTOSO\svc_auto', $securePass)
# CORRECT — retrieve from Windows Credential Manager
$stored = Get-StoredCredential -Target 'MyApp-ServiceAccount'
# CORRECT — use SecretManagement module (Azure Key Vault, etc.)
$secret = Get-Secret -Name 'ServiceAccountPassword' -Vault 'AzureKeyVault'
Prevent Command Injection with Invoke-Expression
Invoke-Expression executes any string as PowerShell code. If that string contains user-controlled input, an attacker can inject arbitrary commands. This is PowerShell’s equivalent of SQL injection. Avoid Invoke-Expression entirely in production scripts — almost every legitimate use case has a safer alternative.
# DANGEROUS — user input can inject commands
$userInput = Read-Host 'Enter module name'
Invoke-Expression "Import-Module $userInput" # attacker enters: SomeModule; Remove-Item C:\ -Recurse
# SAFE — use Import-Module directly with validated input
$allowedModules = @('ActiveDirectory','SqlServer','ImportExcel')
$userInput = Read-Host 'Enter module name'
if ($userInput -in $allowedModules) {
Import-Module $userInput
} else {
Write-Warning "Module '$userInput' is not in the allowed list"
}
# SAFE alternative to dynamic code execution
# Instead of: Invoke-Expression "Get-$objectType"
# Use:
$getterMap = @{
User = { Get-ADUser @args }
Computer = { Get-ADComputer @args }
Group = { Get-ADGroup @args }
}
if ($getterMap.ContainsKey($objectType)) { & $getterMap[$objectType] -Filter * }
Script Signing and AllSigned Policy
Code signing ensures scripts have not been modified since they were authorized. The AllSigned execution policy requires all scripts and module files to be signed by a trusted publisher. This significantly raises the bar for running unauthorized or modified scripts in your environment.
# Check current execution policy (scoped)
Get-ExecutionPolicy -List
# Set AllSigned for the local machine (run as admin)
Set-ExecutionPolicy -ExecutionPolicy AllSigned -Scope LocalMachine -Force
# Sign a script before distributing
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath C:\Scripts\Deploy.ps1 -Certificate $cert `
-TimestampServer 'http://timestamp.digicert.com'
# Verify signature
$sig = Get-AuthenticodeSignature C:\Scripts\Deploy.ps1
if ($sig.Status -ne 'Valid') { throw "Script signature invalid: $($sig.Status)" }
Constrained Language Mode
Constrained Language Mode (CLM) restricts the .NET types, methods, and language features available to a PowerShell session. Enable it via AppLocker or WDAC policies to prevent attackers from using PowerShell to call arbitrary .NET classes even if they can run code. Check the current mode with $ExecutionContext.SessionState.LanguageMode.
# Check current language mode
$ExecutionContext.SessionState.LanguageMode
# Returns: FullLanguage (default) or ConstrainedLanguage (restricted)
# In ConstrainedLanguage mode, these are blocked:
# - Add-Type
# - [Reflection.Assembly]::Load
# - Direct .NET method calls on non-allowed types
# - COM object creation
# CLM is configured via AppLocker/WDAC — not set in PowerShell directly
# To test your scripts in CLM:
$env:__PSLockdownPolicy = 4 # simulate CLM in testing (PS7 only, not production)
Audit Logging with AMSI
The Antimalware Scan Interface (AMSI) lets security software scan PowerShell script content at runtime. PowerShell’s enhanced logging (Script Block Logging and Module Logging) records every script block executed to the Windows event log. Enable these via Group Policy for comprehensive audit trails.
# Enable Script Block Logging via registry (run as admin)
$sbLoggingPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $sbLoggingPath -Force | Out-Null
Set-ItemProperty -Path $sbLoggingPath -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord
Set-ItemProperty -Path $sbLoggingPath -Name 'EnableScriptBlockInvocationLogging' -Value 1 -Type DWord
# Enable Module Logging
$mlPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
New-Item -Path $mlPath -Force | Out-Null
Set-ItemProperty -Path $mlPath -Name 'EnableModuleLogging' -Value 1 -Type DWord
# Query script block log entries
Get-WinEvent -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'
Id = 4104
} -MaxEvents 10 | Select-Object TimeCreated, Message
Principle of Least Privilege for Scripts
Scripts should run with the minimum permissions necessary. Avoid running automation under Domain Admin accounts. Create dedicated service accounts with only the permissions the script needs. Use Just Enough Administration (JEA) to grant constrained remote administrative access without full admin rights.
# Check what rights the current session has
whoami /all
# Test if elevated (admin)
$elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]'Administrator')
Write-Host "Running as admin: $elevated"
# For scheduled tasks: use a dedicated service account, not Domain Admin
# Grant only the specific AD, file, or service permissions needed
# Example: grant a service account rights to reset AD passwords only via delegation
Common Errors and Fixes
- Credentials in version control — use secret management modules. A
git logsearch for “password” in your repository history may reveal credentials committed years ago. Usegit filter-repoto scrub history if this happens. Going forward, use.gitignorefor config files containing secrets and store credentials in vaults rather than scripts. - Invoke-Expression on user input allows arbitrary code execution. Even “safe-looking” user input like a module name can contain
;characters that chain additional commands. Use explicit validation against allowlists,ValidateSetparameter validation, or avoidInvoke-Expressionentirely by redesigning the logic to use parameter-driven cmdlet calls.
Related Cmdlets / See Also
Wrapping Up
Secure PowerShell scripting is a mindset: no plain-text secrets, no dynamic code execution on untrusted input, signed scripts in controlled environments, minimal privileges for service accounts, and comprehensive logging. These practices take minutes to implement and block entire categories of attack vectors. Apply them from the start of every new script project, not after a security incident forces the issue.


