PowerShell Execution Policy on Windows Server: Signing Scripts and Blocking the Right Way

Execution policy is not a security boundary, and treating it as one is why so many Windows Server hardening projects quietly fail. An administrator can bypass any policy with a single command-line switch, so the policy exists to prevent accidental execution, not to stop a determined attacker. Used correctly, though, it is the cheapest control available for stopping a half-edited maintenance script from running on production. This is how to configure it deliberately, with real signing rather than a blanket Unrestricted.

The Five Scopes and What They Really Allow

PolicyBehaviourAppropriate use
RestrictedNo scripts run at all; interactive commands onlyDefault on client Windows
AllSignedOnly scripts with a trusted signature runProduction servers with an internal CA
RemoteSignedLocal scripts run unsigned; downloaded scripts must be signedWorkstations, dev boxes
UnrestrictedEverything runs; download warnings for internet filesNever on a server
BypassNothing is blocked or warnedPurpose-built automation hosts only

Execution policy resolves by scope in a fixed precedence order: MachinePolicy, UserPolicy, Process, CurrentUser, LocalMachine. A Group Policy setting always wins over anything you set locally, which is why Set-ExecutionPolicy appears to do nothing on a domain-joined server. Check the effective value and its source before editing anything:

Get-ExecutionPolicy -List

If MachinePolicy shows a value, stop and change the GPO. Local edits are wasted effort.

Build an Internal Code-Signing Certificate

For a production AllSigned policy you need a code-signing certificate that every server trusts. An internal CA is the right answer; a self-signed certificate works for a closed lab. Create one on the management host and distribute the public part through Group Policy trust:

$cert = New-SelfSignedCertificate `
  -Subject "CN=Contoso Code Signing" `
  -Type CodeSigningCert `
  -KeyUsage DigitalSignature `
  -CertStoreLocation Cert:\LocalMachine\My `
  -NotAfter (Get-Date).AddYears(3)

Export-Certificate -Cert $cert -FilePath C:\pki\codesign.cer

Deploy codesign.cer to Trusted Publishers and Trusted Root Certification Authorities on every server via GPO. Then sign each script and verify the signature chain resolves:

$signed = Set-AuthenticodeSignature -FilePath C:\Scripts\Rotate-IISLogs.ps1 `
  -Certificate $cert `
  -TimestampServer "http://timestamp.digicert.com"

$signed.Status   # expect: Valid

Always timestamp. Without a timestamp counter-signature, every signed script stops validating the moment the certificate expires, and you will be re-signing a whole repository at the worst possible time.

Enforcing AllSigned Without Breaking Scheduled Tasks

  • Set the policy at machine scope, then test one script. Set-ExecutionPolicy AllSigned -Scope LocalMachine -Force, then run a signed maintenance script manually before trusting automation.
  • Watch for unsigned dot-sourcing. A signed wrapper that dot-sources an unsigned helper fails at runtime, not at signature verification. Sign every file in the call chain.
  • Fix the Task Scheduler action. Tasks configured with -ExecutionPolicy Bypass will look like a policy failure in monitoring. Either sign the script or convert the task to -File with a signed path and remove the bypass.
  • Handle third-party modules. Vendor modules that ship unsigned cannot be loaded under AllSigned. Whitelist them by trust path or request signed builds rather than downgrading the whole policy.
  • Log blocked attempts. Enable PowerShell script block logging (Event ID 4104) and module logging so a blocked script produces an audit trail instead of a silent failure.

Fallback: Constrained Language Mode

Execution policy stops scripts, but it does not restrict what interactive PowerShell can do. Where you need a real boundary, combine AllSigned with AppLocker or WDAC to force Constrained Language mode, which blocks COM object creation and arbitrary .NET type invocation. That combination is what turns PowerShell from a convenience into a controlled administration surface. Verify mode with $ExecutionContext.SessionState.LanguageMode — expect ConstrainedLanguage and not FullLanguage.

Servers built for automation benefit from getting this right early, before dozens of unsigned scripts accumulate. That discipline fits naturally with the deployment patterns in the Windows server automation and PowerShell guides, where signed artefacts move through the same pipeline as the applications they support.

Migrating an Existing Unsigned Script Estate

The hard case is not a new server but a five-year-old one with two hundred loosely organised scripts and no record of which still run. Do not switch straight to AllSigned. Audit what is actually scheduled first, using Task Scheduler inventory plus a scan for script files, and export owner and last-run date for each. Retire anything nobody recognises, sign what remains, then flip the policy. Doing the flip first produces a cascade of failures across unrelated maintenance jobs at unpredictable times of day, which is how a hardening project gets quietly reverted.

Common Misconfigurations That Cause Policy Breaks

One persistent source of confusion is the difference between machine-scope and user-scope policy. Setting AllSigned at machine scope does not stop a user from running Set-ExecutionPolicy -Scope CurrentUser RemoteSigned unless user-scope policy is locked by Group Policy, which then takes precedence and cannot be overridden locally. Where you need a strong boundary, configure the setting in the Computer Configuration node of a GPO so the Process and CurrentUser scopes are effectively pinned. Verify the result with Get-ExecutionPolicy -List and confirm that MachinePolicy and UserPolicy are the values actually in force rather than the local ones.

Bypass Is Not the Enemy

A final note on discipline: automation platforms legitimately need -ExecutionPolicy Bypass when they launch a signed wrapper from an agent process. The mistake is leaving the bypass switch on every scheduled task indefinitely, which quietly defeats the policy across the whole estate. Audit task actions quarterly and remove the switch wherever the script is signed and the object is to enforce the chain rather than skip it.

Deployment Checklist

Set the policy via GPO, distribute the signing certificate to Trusted Publishers, sign with a timestamp, verify with Get-AuthenticodeSignature, and monitor Event ID 4104 for blocked executions. Test the whole chain on a disposable Windows test instance before you touch production, because a wrong policy on a live server means every scheduled task that shells out to PowerShell fails at the same moment.

Leave a Comment