Code signing is a cryptographic mechanism that applies a digital signature to PowerShell scripts (.ps1), module files (.psm1), and manifests (.psd1) using Microsoft Authenticode technology. Signing scripts establishes authenticity (verifying the identity of the publisher) and integrity (guaranteeing that the code has not been altered or tampered with since the signature was applied). Combined with the AllSigned or RemoteSigned execution policy, signing is the mechanism that lets trusted scripts run while blocking unsigned or tampered ones.
Why Sign PowerShell Scripts
Signing a script provides two guarantees:
- Integrity i.e. the script's content hasn't changed since it was signed. Any modification, even a single character, invalidates the signature.
- Authenticity i.e. the signature is tied to a certificate, verifying who signed the script.
In enterprise environments governed by security baselines and application control policies, executing unsigned scripts poses serious risks. Code signing solves several key operational and security challenges:
- Tamper Evidence: digital signatures embed a cryptographic hash of the script body. If an attacker, user, or transmission error alters a single byte or space in the file, the hash check fails and invalidates the signature.
- Publisher Identity: scripts signed by an internal Public Key Infrastructure (PKI) or public Certificate Authority (CA) prove that the code originated from an authorized internal team.
- Execution Policy Compliance: an execution policy set to
AllSignedblocks every script unless it carries a valid signature issued by a certificate trusted in the host machine'sTrusted Publishersstore.
When Signing Matters
| Scenario | Why Signing Helps |
|---|---|
| Distributing scripts across an organization | Ensures scripts weren't tampered with in transit |
Enforcing AllSigned execution policy |
Required for any script to run, including your own |
| Publishing a module publicly | Builds trust with users installing your code |
| Compliance/audit requirements | Provides a verifiable chain of authorship |
Creating a Code Signing Certificate with New-SelfSignedCertificate
In production, certificates are typically distributed by an enterprise Certificate Authority (Active Directory Certificate Services) or a commercial CA. For lab environments or internal development, you can generate a self-signed code signing certificate using New-SelfSignedCertificate.
$cert = New-SelfSignedCertificate -Type CodeSigningCert `
-Subject "CN=PowerShellInternalSigner" `
-CertStoreLocation "Cert:\CurrentUser\My" `
-NotAfter (Get-Date).AddYears(2)
[PSCustomObject]@{
Subject = $cert.Subject
Thumbprint = $cert.Thumbprint
Expires = $cert.NotAfter
}where:
New-SelfSignedCertificateis a cmdlet that creates and enrolls a local X.509 certificate.-Type CodeSigningCertpre-configures the Enhanced Key Usage (EKU) extension for Code Signing (OID 1.3.6.1.5.5.7.3.3).-Subjectis the distinguished name identifying the publisher (e.g.,CN=PowerShellInternalSigner).-CertStoreLocationspecifies where the private and public keys are saved (Cert:\CurrentUser\My).-NotAftersets the expiration window (defaults to one year; configured above for two years).
./generate-signing-cert.ps1Subject Thumbprint Expires
------- ---------- -------
CN=PowerShellInternalSigner 4A7B1C8D9E0F2A3B4C5D6E7F8A9B0C1D2E3F4A5B 3/31/2028 12:00:00 PMTrusting a Self-Signed Certificate Locally
A self-signed certificate is not trusted by Windows by default. For the system to recognize it as valid, you must export its public certificate (.cer) and install it into both the Trusted Root Certification Authorities and Trusted Publishers stores.
$rootStore = [System.Security.Cryptography.X509Certificates.X509Store]::new("Root", "CurrentUser")
$rootStore.Open("ReadWrite")
$rootStore.Add($cert)
$rootStore.Close()
$publisherStore = [System.Security.Cryptography.X509Certificates.X509Store]::new("TrustedPublisher", "CurrentUser")
$publisherStore.Open("ReadWrite")
$publisherStore.Add($cert)
$publisherStore.Close()Signing a Script with Set-AuthenticodeSignature
The Set-AuthenticodeSignature cmdlet computes a cryptographic hash of the script, encrypts the hash with the certificate's private key, and appends a formatted Authenticode signature block to the end of the file.
$scriptPath = ".\Deploy.ps1"
Set-Content -Path $scriptPath -Value "Write-Output 'Executing production deployment...'"
$signerCert = Get-ChildItem -Path "Cert:\CurrentUser\My" -CodeSigningCert |
Where-Object -Property Subject -Match "PowerShellInternalSigner" |
Select-Object -First 1
Set-AuthenticodeSignature -FilePath $scriptPath `
-Certificate $signerCert `
-TimestampServer "http://tsa.open-tsa.eu"where:
Set-AuthenticodeSignaturesigns the target file using the designated certificate.-FilePathis the path to the script or module file to sign.-Certificateis theSystem.Security.Cryptography.X509Certificates.X509Certificate2object with an accessible private key.-TimestampServeris a public RFC 3161 timestamping authority URL. Timestamping guarantees that the signature remains legally valid even after the signing certificate expires, provided the certificate was active at the time of signing.
./sign-script-demo.ps1SignerCertificate Status Path
----------------- ------ ----
4A7B1C8D9E0F2A3B4C5D6E7F8A9B0C1D2E3F4A5B Valid Deploy.ps1Write-Output 'Executing production deployment...'Verifying a Signature with Get-AuthenticodeSignature
The Get-AuthenticodeSignature cmdlet inspects an on-disk script, extracts the signature block, validates the integrity hash against the file contents, and checks the certificate trust chain.
$sig = Get-AuthenticodeSignature -FilePath ".\Deploy.ps1"
[PSCustomObject]@{
FilePath = $sig.Path
Status = $sig.Status
StatusMsg = $sig.StatusMessage
SignerCN = $sig.SignerCertificate.Subject
HasTimestamp= ($null -ne $sig.TimeStamperCertificate)
}where:
Get-AuthenticodeSignatureanalyzes the file and returns aSignatureobject.$sig.Statusreturns an enumerated status string (see table below).$sig.TimeStamperCertificateverifies whether an external timestamp authority validated the signing time.
./verify-signature-demo.ps1FilePath : C:\Scripts\Deploy.ps1
Status : Valid
StatusMsg : Signature verified.
SignerCN : CN=PowerShellInternalSigner
HasTimestamp : TrueUnderstanding Status Values
| Status | Meaning |
|---|---|
Valid |
Signature is cryptographically sound and the certificate is trusted. |
NotSigned |
The script has no signature at all. |
HashMismatch |
The script was modified after signing, so the integrity check failed. |
NotTrusted |
The signature is valid, but the certificate isn't trusted on this machine. |
UnknownError |
Signature validation failed for another reason (e.g., expired certificate without a timestamp). |
Practical Usage: Signing and Verifying a Deployment Script
A common workflow combines signing during release with automatic verification before execution, ensuring only untampered, trusted scripts run in production.
param(
[Parameter(Mandatory)]
[string]$ScriptPath
)
$cert = Get-ChildItem -Path "Cert:\CurrentUser\My" -CodeSigningCert | Select-Object -First 1
if (-not $cert) {
throw "No code signing certificate found in the current user's certificate store."
}
Set-AuthenticodeSignature -FilePath $ScriptPath -Certificate $cert -TimestampServer "http://tsa.open-tsa.eu"
$signature = Get-AuthenticodeSignature -FilePath $ScriptPath
if ($signature.Status -eq "Valid") {
Write-Output "$ScriptPath signed successfully and verified as Valid."
} else {
Write-Output "Signing completed, but verification returned: $($signature.Status)"
}where:
Get-ChildItem -CodeSigningCertfinds the first available code-signing certificate in the personal store.- The script signs the file and immediately re-verifies it, confirming the signature was applied correctly.
./sign-and-verify.ps1 -ScriptPath "C:\Scripts\deploy.ps1"deploy.ps1 signed successfully and verified as Valid.Practical Administration: Automated Script Tamper and Integrity Audit
In regulated environments where execution policies are set to AllSigned, administrators must audit operational directories for unsigned scripts, broken signatures, or tampered code before triggering automated execution pipelines.
param (
[string]$Directory = "C:\ProductionScripts"
)
$files = Get-ChildItem -Path $Directory -Filter "*.ps1" -Recurse
$auditResults = foreach ($file in $files) {
$sig = Get-AuthenticodeSignature -FilePath $file.FullName
$compliance = switch ($sig.Status) {
"Valid" { "Compliant" }
"NotSigned" { "BLOCKED: Unsigned File" }
"HashMismatch" { "DANGER: File Tampered / Corrupted" }
Default { "WARNING: Untrusted ($($sig.Status))" }
}
[PSCustomObject]@{
FileName = $file.Name
Compliance = $compliance
Status = $sig.Status
Signer = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "None" }
}
}
$auditResults | Format-Table -AutoSizewhere:
Get-ChildItem -Filter "*.ps1"recursively collects all candidate PowerShell scripts.switch ($sig.Status)evaluates signature states to assign operational compliance labels.HashMismatchexplicitly flags scripts whose contents have changed since they were signed.
./audit-script-signatures.ps1 -Directory "C:\ProductionScripts"FileName Compliance Status Signer
-------- ---------- ------ ------
Deploy.ps1 Compliant Valid CN=PowerShellInternalSigner
HealthCheck.ps1 DANGER: File Tampered / Corrupted HashMismatch CN=PowerShellInternalSigner
AdHocPatch.ps1 BLOCKED: Unsigned File NotSigned None