PowerShell sits at the center of Windows automation, which makes it both indispensable and high-impact when misused. Hardening PowerShell is not about blocking administrators; it is about making legitimate work observable, reducing attack surface, and ensuring that risky actions require intent. This practitioner guide walks through a safe, reversible path to harden PowerShell in real environments. You will take an inventory of versions and exposure, set an execution policy that fits your stage, sign scripts with a local code-signing certificate, enable logging you can actually use, lock down where scripts live and who can write them, use remoting over HTTPS and restrict it, apply Just Enough Administration (JEA) to limit blast radius, handle secrets without plain text, and verify results, diagnose issues, and roll back cleanly. Scope this work to a pilot first (for example, one admin workstation and one non-production server). Expand only after you can verify outcomes end-to-end.
Version and environment inventory
Before changing anything, capture the current state. Run the following on each pilot system and save the output to a versioned note or ticket.
- PowerShell versions and language mode:
$PSVersionTable$ExecutionContext.SessionState.LanguageMode- OS build:
(Get-ComputerInfo).OsName, (Get-ComputerInfo).OsVersion- Execution policy precedence:
Get-ExecutionPolicy -List- Existing remoting listeners:
Test-WSMan(HTTP)Test-WSMan -UseSSL(HTTPS)- Script directories and their ACLs:
Get-Acl C:\Ops\Scripts | Format-List- Installed modules that interact with secrets:
Get-Module -ListAvailable Microsoft.PowerShell.Secret*
A concise capture sheet helps operations compare before/after states:
| Item | Sample command | Expected pilot output |
|---|---|---|
| PowerShell version | $PSVersionTable.PSVersion | 5.1.x or 7.x.x |
| Language mode | $ExecutionContext.SessionState.LanguageMode | FullLanguage |
| Execution policy | Get-ExecutionPolicy -List | MachinePolicy/AllSigned or RemoteSigned per scope |
| WinRM HTTPS | Test-WSMan -UseSSL | Succeeds on target hosts only |
| Script path ACL | Get-Acl C:\Ops\Scripts | Write restricted to Administrators |
Safe configuration path
Implement controls in the order below. After each step, verify and snapshot the state. Apply to a small pilot first.
1) Choose a practical execution policy
For pilots, use RemoteSigned to block internet-downloaded unsigned scripts while allowing local scripts. For stricter environments, AllSigned requires every script and module to be signed.
# RemoteSigned for pilots
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope LocalMachine -Force
# Or, stricter: AllSigned
# Set-ExecutionPolicy -ExecutionPolicy AllSigned -Scope LocalMachine -Force
# Ensure current session matches intended policy
Get-ExecutionPolicy -List
Notes:
- Group Policy takes precedence (MachinePolicy/UserPolicy). If set by GPO, adjust there.
- Execution policy is not a security boundary; it is part of defense-in-depth.
2) Create a local code-signing certificate and sign scripts
Sign your production scripts so changes are intentional and traceable.
# Create a self-signed code-signing cert in CurrentUser store
$cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=Local Code Signing" -CertStoreLocation Cert:\CurrentUser\My
# Trust the cert for this user (pilot scope). For wider scope, import to machine TrustedPublisher.
Export-Certificate -Cert $cert -FilePath "$env:TEMP\codesign.cer" | Out-Null
Import-Certificate -FilePath "$env:TEMP\codesign.cer" -CertStoreLocation Cert:\CurrentUser\TrustedPublisher | Out-Null
# Sign a script
Set-AuthenticodeSignature -FilePath .\Maintenance.ps1 -Certificate $cert | Format-List Status, StatusMessage, SignerCertificate
# Verify signature status
Get-AuthenticodeSignature .\Maintenance.ps1 | Format-List Status, IsOSBinary, SignerCertificate
Expected:
- Status should be Valid.
- If AllSigned is enabled, unsigned scripts fail to run with a clear error.
Keep the private key secure. For self-signed certs without a timestamp, scripts must be re-signed before cert expiry.
3) Enable PowerShell logging you can use
Enable transcription, script block logging, and (optionally) module logging. Apply via registry on pilots; move to GPO for scale.
# Transcription
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -Name EnableTranscripting -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -Name IncludeInvocationHeader -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -Name OutputDirectory -PropertyType String -Value 'C:\PowerShellTranscripts' -Force | Out-Null
# Script Block Logging
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Name EnableScriptBlockLogging -PropertyType DWord -Value 1 -Force | Out-Null
# Optional: Module Logging for specific modules
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' -Name EnableModuleLogging -PropertyType DWord -Value 1 -Force | Out-Null
New-Item -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames' -Force | Out-Null
New-ItemProperty -Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging\ModuleNames' -Name '*' -PropertyType String -Value '*' -Force | Out-Null
# Ensure transcript path exists and is protected
New-Item -ItemType Directory -Path 'C:\PowerShellTranscripts' -Force | Out-Null
icacls C:\PowerShellTranscripts /inheritance:r /grant:r Administrators:(OI)(CI)(F) System:(OI)(CI)(F) /deny Users:(OI)(CI)(W)
Expected:
- Transcripts under C:\PowerShellTranscripts with time-stamped files.
- Event Viewer shows script block events in Microsoft-Windows-PowerShell/Operational (IDs often include 4104).
4) Lock down where scripts live
Store scripts in a controlled folder and restrict write access.
New-Item -ItemType Directory -Path 'C:\Ops\Scripts' -Force | Out-Null
icacls C:\Ops\Scripts /inheritance:r /grant:r Administrators:(OI)(CI)(M) System:(OI)(CI)(F) /grant:r Users:(OI)(CI)(RX)
# Confirm ACLs
Get-Acl C:\Ops\Scripts | Format-List
Expected:
- Only Administrators can modify.
- Non-admin users can read and execute only.
5) Configure PowerShell remoting over HTTPS and restrict it
If you use remoting, prefer HTTPS on 5986 and limit who can connect and from where.
# Create a machine certificate for WinRM HTTPS
$cert = New-SelfSignedCertificate -DnsName $(hostname) -CertStoreLocation Cert:\LocalMachine\My -FriendlyName 'WinRM HTTPS'
# Remove HTTP listener (if present)
Get-ChildItem WSMan:\Localhost\Listener | Where-Object { $_.Keys -match 'Transport=HTTP' } | ForEach-Object { Remove-Item $_.PSPath -Recurse -Force }
# Create HTTPS listener
New-Item -Path WSMan:\LocalHost\Listener -Transport HTTPS -Address * -CertificateThumbPrint $cert.Thumbprint -Force | Out-Null
# Open firewall only to a management subnet (example 10.0.0.0/24)
New-NetFirewallRule -DisplayName 'WinRM HTTPS Inbound 5986 (Mgmt Subnet)' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 5986 -RemoteAddress 10.0.0.0/24
# Test locally
Test-WSMan -UseSSL
Expected:
- Test-WSMan -UseSSL succeeds.
- HTTP remoting on 5985 is unavailable.
- Remote access is allowed only from specified subnets.
6) Apply Just Enough Administration (JEA)
Limit what remote users can do by exposing only the commands they need.
# Create a JEA session configuration file allowing a minimal set of cmdlets
$jeaConfig = @{
SessionType = 'RestrictedRemoteServer'
TranscriptDirectory = 'C:\PowerShellTranscripts'
RunAsVirtualAccount = $true
VisibleCmdlets = @(
'Get-Service',
@{ Name = 'Restart-Service'; Parameters = @{ Name = 'Name'; ValidateSet = 'Spooler','W32Time' } },
'Get-EventLog'
)
}
New-PSSessionConfigurationFile -Path .\JEA-Maint.pssc @jeaConfig
# Register the configuration
Register-PSSessionConfiguration -Name 'JEA_Maint' -Path .\JEA-Maint.pssc -Force
# Verify configuration is active
Get-PSSessionConfiguration -Name 'JEA_Maint' | Format-List Name, Permission
Expected:
- Entering a session with
Enter-PSSession -ComputerName <host> -ConfigurationName JEA_Maint -UseSSLprovides a restricted set of commands. - Disallowed commands fail with a clear error.
7) Handle secrets safely
Avoid plain text. Use SecretManagement with SecretStore for local development and operations.
# Install once per machine (requires admin for system scope)
Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser -Force
# Register a local vault
Register-SecretVault -Name 'LocalStore' -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
# Initialize vault (it will prompt to set a password)
Set-Secret -Name 'TestSecret' -Secret (ConvertTo-SecureString 'example-value' -AsPlainText -Force)
# Retrieve securely
$secret = Get-Secret -Name 'TestSecret'
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret))
# Clean up sensitive plain text promptly
$plain = $null; [GC]::Collect(); [GC]::WaitForPendingFinalizers()
Expected:
- Get-Secret returns a SecureString or typed secret.
- Secret material is not stored in script files or environment variables.
Verification and diagnostics
After each control, test the intended behavior and observe logs.
- Execution policy and signing
- Try running an unsigned script from your controlled folder.
- Expected: RemoteSigned blocks scripts with the Internet Mark of the Web; AllSigned blocks all unsigned scripts.
- Verify:
Get-ExecutionPolicy -ListGet-AuthenticodeSignature .\Maintenance.ps1
- Logging
- Run a few commands, then check:
- Transcripts:
dir C:\PowerShellTranscripts - Event log: Event Viewer -> Applications and Services Logs -> Microsoft -> Windows -> PowerShell -> Operational
- Look for event IDs such as 4103 (module logging) and 4104 (script block logging).
- Access control on scripts
- As a non-admin, attempt to create a file under C:\Ops\Scripts.
- Expected: Access denied.
- Verify ACLs:
Get-Acl C:\Ops\Scripts | Format-List
- Remoting over HTTPS
- From an allowed host:
Test-WSMan <server> -UseSSL-> success. - From a disallowed host:
Test-WSMan <server> -UseSSL-> fails. - Confirm listener:
Get-ChildItem WSMan:\LocalHost\Listener
- JEA endpoint
- Enter JEA session and list commands:
Enter-PSSession -ComputerName <server> -ConfigurationName JEA_Maint -UseSSLGet-Command- Try a disallowed command like
Get-Process. - Expected: Access denied error for disallowed commands.
- Secrets
Get-Secret -Name TestSecretreturns without writing secrets to stdout in plain text.- Search scripts for hardcoded secrets:
Select-String -Path C:\Ops\Scripts\*.ps1 -Pattern 'password|token|secret'
A compact verification matrix helps during change windows:
| Control | Goal | Key verify | Expected result |
|---|---|---|---|
| Execution policy | Block untrusted code | Get-ExecutionPolicy -List | RemoteSigned/AllSigned in effect |
| Signing | Allow only signed scripts | Get-AuthenticodeSignature | Status = Valid |
| Logging | Observe actions | Check transcripts, Event 4103/4104 | Events and files present |
| Remoting | Encrypted, scoped access | Test-WSMan -UseSSL | Only from allowed subnets |
| JEA | Least-privileged tasks | Get-Command in session | Only allowed cmdlets visible |
| Secrets | No plain text | Get-Secret, repo scan | No secrets in code/files |
Failure modes and recovery
Plan for breakage and keep rollback steps ready.
- AllSigned breaks existing automation
- Symptom: Unsigned scripts fail.
- Diagnosis: Error mentions signing requirement.
- Recovery:
- Temporarily set process-level bypass during maintenance window only:
powershell.exe -ExecutionPolicy Bypass - Prefer safer fix: sign scripts with your code-signing certificate.
- Or relax to RemoteSigned on pilot:
Set-ExecutionPolicy RemoteSigned -Scope LocalMachine -Force.
- Excessive log volume or sensitive data in transcripts
- Symptom: Large event logs or transcripts.
- Diagnosis: Disk growth on transcript folder; busy 4103/4104 events.
- Recovery:
- Scope ModuleLogging to specific modules by name instead of *.
- Rotate and archive transcript directory; adjust ACLs; consider excluding high-volume lab hosts.
- HTTPS remoting fails due to certificate trust
- Symptom: Test-WSMan -UseSSL fails; certificate errors on client.
- Diagnosis: Hostname mismatch or untrusted issuer.
- Recovery:
- Reissue cert with exact DNS name used by clients.
- Distribute the issuing certificate to Trusted Root and Trusted Publisher where required.
- For testing only,
Enter-PSSession -UseSSL -SkipCACheck -SkipCNCheck(do not use in production).
- JEA denies required tasks
- Symptom: Needed cmdlet missing; errors about visibility.
- Diagnosis: Review VisibleCmdlets in the .pssc file.
- Recovery:
- Edit the configuration file to add exact cmdlets/parameters.
Register-PSSessionConfiguration -Name 'JEA_Maint' -Path .\JEA-Maint.pssc -Force.
- Secret vault locked or unavailable
- Symptom: Get-Secret prompts or fails.
- Diagnosis: SecretStore is locked or profile changed.
- Recovery:
- Unlock-SecretStore and retry.
- Re-register vault if profile changed:
Register-SecretVault -Name 'LocalStore' .... - Rotate vault password and re-seed minimal secrets.
- ACL change blocks scheduled tasks
- Symptom: Scheduled task fails to write logs or import modules from C:\Ops\Scripts.
- Diagnosis: Event Viewer and task history show access denied.
- Recovery:
- Grant modify rights to the task's service account on specific folders.
- Re-test with whoami in task and icacls review.
- Language mode constraints appear unexpectedly
- Symptom: Some .NET types or Add-Type blocked.
- Diagnosis:
$ExecutionContext.SessionState.LanguageModeshows ConstrainedLanguage. - Recovery:
- Review Device Guard/WDAC policies and adjust trusted signers or allowlists as required.
Quick rollback cookbook
- Execution policy:
Set-ExecutionPolicy -ExecutionPolicy Undefined -Scope LocalMachine -Force- Disable transcription/logging (pilot rollback):
Remove-Item 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription' -Recurse -ForceRemove-Item 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging' -Recurse -ForceRemove-Item 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging' -Recurse -Force- Remoting listeners:
Get-ChildItem WSMan:\Localhost\Listener | Remove-Item -Recurse -Force- Re-enable defaults only if needed:
Enable-PSRemoting -Force - JEA endpoint:
Unregister-PSSessionConfiguration -Name 'JEA_Maint' -Force- Trusted cert cleanup (pilot user scope):
Get-ChildItem Cert:\CurrentUser\TrustedPublisher | Where-Object { $_.Subject -like 'Local Code Signing' } | Remove-Item -Force
Operations checklist
Run this checklist during initial rollout and at regular intervals (for example, quarterly or after OS/PowerShell upgrades).
- Inventory
- Capture
$PSVersionTable, language mode, andGet-ExecutionPolicy -List. - Confirm WinRM HTTPS listener and firewall scoping.
- Execution and signing
- Ensure RemoteSigned or AllSigned is enforced as intended.
- Verify all production scripts/modules have Valid signatures.
- Logging and observability
- Confirm transcription to protected directory and event IDs 4103/4104 present.
- Review a random sample of transcripts for completeness and sensitive data.
- Access control
- Re-check ACLs on C:\Ops\Scripts and transcript folder.
- Attempt a write as a non-admin user (expect denial).
- Remoting
- Test-WSMan -UseSSL from allowed and disallowed hosts (expect allow/deny).
- Review Get-PSSessionConfiguration for JEA endpoints only as needed.
- JEA
- Enter JEA session and confirm only required cmdlets are visible.
- Attempt a disallowed command (expect denial).
- Secrets
- Get-Secret returns without leakage; rotate vault password if scheduled.
- Scan scripts for hardcoded secrets with Select-String.
- Documentation and recovery
- Validate rollback steps are current and tested on a lab host.
- Snapshot configurations and event logs for audit.
Conclusion
You can harden PowerShell without stalling development or operations by working in small, verifiable steps. Start with a narrow pilot, capture the baseline, and implement execution policy, signing, logging, access control, HTTPS remoting, JEA, and sane secrets handling. After each change, verify behavior and observability, document the outcome, and keep a tested rollback path. Once the pilot is stable and measurable, widen the scope in phases using the same checklist and diagnostics. Hardening is not one setting; it is a set of practical, observable controls that limit what can go wrong and make legitimate work traceable. With the steps above, you will raise your security posture while keeping everyday automation productive.