Skip to content

06 · Security

Automation scripts routinely need secrets — API keys, service account passwords, connection strings — which makes them a common place for those secrets to leak: hardcoded in source, printed to a log, or sitting as plaintext in a variable that ends up in an error message or transcript. This module covers PowerShell's tools for handling secrets more carefully, plus the security controls people commonly over-trust.

SecureString and PSCredential

$secure = ConvertTo-SecureString "P@ssw0rd123" -AsPlainText -Force
$cred = [PSCredential]::new("svc-account", $secure)

$cred.UserName
$cred.Password.GetType().Name
svc-account
SecureString

SecureString keeps the characters encrypted in memory rather than as a plain managed string, and — critically — most cmdlets that accept credentials print System.Security.SecureString instead of the actual value if you accidentally output it. It's damage limitation, not unbreakable encryption: anyone with debugger-level access to the process can still recover it, and .NET has actually deprecated SecureString in newer runtimes in favor of secret-vault patterns — but for scripts that still need to pass a credential around, it beats a bare string.

$plain = $cred.GetNetworkCredential().Password
"Length: $($plain.Length)"
Length: 11

GetNetworkCredential().Password is the sanctioned way to get the plaintext back out — decrypt at the last possible moment, right before handing it to whatever actually needs it (an API call, a connection string), and let the plaintext variable go out of scope immediately after.

ConvertTo-SecureString -AsPlainText -Force is a smell, not a solution

Note that in the example above, we started from plaintext to build the demo — that line is exactly what you should almost never write in real code, because the plaintext already exists as a literal in your source by the time you convert it. ConvertTo-SecureString is meant to receive an already-secure string (from Read-Host -AsSecureString, or decrypted from a secret store), not to wrap a hardcoded password. If you see -AsPlainText -Force with a literal string next to it in a script, that's a hardcoded secret with a SecureString costume on.

The trap: a [string] secret parameter leaks everywhere strings do

function Connect-Service {
    param(
        [Parameter(Mandatory)]
        [string]$ApiKey
    )
    "Connecting with key ending in ...$($ApiKey.Substring($ApiKey.Length - 4))"
}
Connect-Service -ApiKey "sk-super-secret-123456"
Connecting with key ending in ...3456

That function works, but $ApiKey as a plain string will show up in full in Get-History, in a PowerShell transcript (Start-Transcript), in verbose tracing, and in any error message that happens to interpolate it. Typing the parameter as [SecureString] instead closes most of those:

function Connect-ServiceSecure {
    param(
        [Parameter(Mandatory)]
        [SecureString]$ApiKey
    )
    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($ApiKey)
    try {
        $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
        "Connecting with key ending in ...$($plain.Substring($plain.Length - 4))"
    } finally {
        [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
    }
}

$secureKey = ConvertTo-SecureString "sk-super-secret-123456" -AsPlainText -Force
Connect-ServiceSecure -ApiKey $secureKey
Connecting with key ending in ...3456

The finally block matters: ZeroFreeBSTR explicitly zeroes the unmanaged memory holding the decrypted value the moment you're done with it, instead of leaving it sitting in memory until garbage collection gets around to it.

Get-FileHash: integrity checks, not password storage

"hello world" | Out-File ./demo.txt
(Get-FileHash -Path ./demo.txt -Algorithm SHA256).Hash
A948904F2F0F479B8F8197694B30184B0D2ED1C1CD2A1EC0FB85D299A192A447

Get-FileHash is for verifying a downloaded file or script hasn't been tampered with (compare against a published hash) — it is not a password-hashing function. Never build "secure" password storage on top of Get-FileHash/SHA256 directly; password hashing needs a slow, salted algorithm (bcrypt/PBKDF2/Argon2), which is outside PowerShell's built-in toolset and belongs in a dedicated identity system, not a script.

The trap: Select-String finds hardcoded secrets you forgot about

"`$password = 'hardcoded-oops'" | Out-File ./bad-script.ps1
Select-String -Path ./bad-script.ps1 -Pattern 'password\s*='
bad-script.ps1:1:$password = 'hardcoded-oops'

Running a quick Select-String -Pattern 'password|apikey|secret|token\s*=' sweep across a script repo before committing is a cheap habit that catches the single most common real-world leak: a secret typed directly into source during testing and never removed.

Execution policy is a guardrail against mistakes, not an attacker

Get-ExecutionPolicy -List
        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy    Unrestricted
   UserPolicy    Unrestricted
      Process    Unrestricted
  CurrentUser    Unrestricted
 LocalMachine    Unrestricted

ExecutionPolicy only gates whether a script runs by double-click or default invocation — it is trivially bypassed (powershell -ExecutionPolicy Bypass -File script.ps1, or piping content through Invoke-Expression) and Microsoft documents it explicitly as not a security boundary. Its real purpose is preventing accidental execution (stopping a downloaded .ps1 from running just because you double-clicked it), not stopping a determined attacker. Don't design a security model that depends on it.

Cheat sheet

Concept Use
SecureString encrypted-in-memory string; damage limitation, not unbreakable
[PSCredential]::new($user, $secureString) pair a username with a secure password
$cred.GetNetworkCredential().Password decrypt only at the point of use
ConvertTo-SecureString -AsPlainText -Force on a literal smell — hardcoded secret in disguise
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR explicitly clear decrypted memory after use
Get-FileHash -Algorithm SHA256 integrity checks — never password storage
Select-String -Pattern 'password\|apikey\|secret' quick pre-commit secret sweep
ExecutionPolicy accident prevention, not an attacker-facing security boundary

How It Actually Works

Script signing verification runs through the same Authenticode mechanism Windows uses for executables: a signed .ps1 has a base64 PKCS#7 signature block appended as a trailing comment (# SIG # Begin signature block), and Get-AuthenticodeSignature/the engine's execution-policy check reads that block, extracts the signer's certificate chain, and validates it against the trusted root/publisher certificate stores using standard X.509 chain-building and revocation checking (CRL/OCSP, if reachable) — this is why moving or re-saving a signed script with a different line-ending style can invalidate its signature: the signature covers the exact byte content of the script up to the signature block, so even a whitespace change makes the hash no longer match.

Execution policy itself is enforced by the engine's script-invocation gate, checked once when a .ps1 file is about to run (not per-line, and not for code typed directly at an interactive prompt or passed via -Command) — this asymmetry is the actual mechanical reason execution policy is explicitly documented as a safety rail, not a security boundary: anyone with the ability to type PowerShell commands interactively, or to invoke powershell -EncodedCommand, bypasses the file-based check entirely because there's no file for the gate to inspect.

Constrained Language Mode and JEA (Just Enough Administration) are the actual security boundaries the docs point toward instead: CLM is enforced by the runspace's LanguageMode property, which the engine consults before allowing certain AST node types (arbitrary .NET type instantiation, Add-Type, COM object creation) to execute at all — under CLM those constructs throw PSSecurityException at parse-adjacent validation time, not merely as policy advice, because the interpreter itself refuses to build/execute those AST nodes when the mode is active.

Exercise

Write a function Test-SecretExposure that scans a directory of .ps1 files with Select-String for common secret patterns (password\s*=, apikey\s*=, -AsPlainText) and returns a report object per match (file, line number, matched text). Run it against a folder containing at least one deliberately planted "leak" and confirm it's caught, then run it against a version using SecureString/Read-Host -AsSecureString properly and confirm that one is clean.