TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Trustworthy Script Signing in PowerShell: Create, Reuse, Timestamp, and Verify in CI

PowerShell makes automation easy—but unsigned scripts make it hard to trust what you run. By signing your scripts, you prove authorship, detect tampering, and enable safer execution policies like AllSigned. In this guide you will create or reuse a local code-signing certificate, sign with a timestamp so signatures remain valid after certificate renewal, and verify signatures locally and in CI. The result: predictable execution, secure distribution, and clearer provenance.

Why sign PowerShell scripts?

  • Provenance: Assure teammates and CI/CD that a script originated from your team.
  • Tamper detection: Any change to the file after signing breaks the signature.
  • Execution policy compatibility: AllSigned and RemoteSigned environments can run your scripts without prompts.
  • Compliance: AppLocker/WDAC and many enterprise policies rely on Authenticode signatures.

Note: Execution Policy is not a security boundary, but signatures raise the bar and integrate well with policy-based controls.

Create and reuse a local code-signing certificate

Self-signed vs. enterprise CA

  • Self-signed: Fast and ideal for local dev and internal testing. You must distribute the public certificate to the trust stores (Trusted Publisher/Root) of machines that need to trust the signatures.
  • Enterprise CA or public CA: Preferred for production and wide distribution. Machines already trust the issuing chain.

Idempotent: create or reuse a local code-signing cert

The following script finds a reusable code-signing certificate by subject. If missing, it creates a new one, signs a demo script, and prints verification details.

$subject = 'CN=CodeSigning-Local'
$cert = Get-ChildItem -Path Cert:\CurrentUser\My -CodeSigningCert |
  Where-Object { $_.Subject -eq $subject } | Select-Object -First 1
if (-not $cert) {
  $cert = New-SelfSignedCertificate -Type CodeSigningCert -Subject $subject -CertStoreLocation 'Cert:\CurrentUser\My'
}

$path = '.\demo.ps1'
if (-not (Test-Path -Path $path)) {
  'Write-Host "Hello from signed script"' | Out-File -FilePath $path -Encoding utf8
}

$ts = 'http://timestamp.digicert.com'
$sig = Set-AuthenticodeSignature -FilePath $path -Certificate $cert -TimestampServer $ts
$check = Get-AuthenticodeSignature -FilePath $path

[pscustomobject]@{
  File   = (Resolve-Path -Path $path).Path
  Status = $check.Status
  Signer = $check.SignerCertificate.Subject
} | Format-List

Tips:

  • Ensure the certificate has the Code Signing EKU (1.3.6.1.5.5.7.3.3).
  • Windows certificate stores used above are available only on Windows. Signing must run on a Windows host.
  • For long-lived certs, consider specifying key length and algorithms (e.g., 3072-bit RSA, SHA-256).

Export and distribute trust (public cert)

To let other machines trust signatures from your self-signed cert, export the public certificate and distribute it. For internal orgs, place it in Trusted Publishers (and, for self-signed, Trusted Root) on target machines.

# Export PFX (private key) for CI use if needed
$pwd = Read-Host -AsSecureString 'PFX password'
Export-PfxCertificate -Cert $cert -FilePath "$env:USERPROFILE\codesign-local.pfx" -Password $pwd | Out-Null

# Export public cert (.cer) for distribution
Export-Certificate -Cert $cert -FilePath "$env:USERPROFILE\codesign-local.cer" | Out-Null

# On target machines (requires admin for LocalMachine stores):
Import-Certificate -FilePath .\codesign-local.cer -CertStoreLocation Cert:\LocalMachine\TrustedPublisher | Out-Null
Import-Certificate -FilePath .\codesign-local.cer -CertStoreLocation Cert:\LocalMachine\Root | Out-Null

Security notes:

  • Protect the PFX with a strong password, restrict file ACLs, and store it in your secret manager/CI secret store.
  • Prefer non-exportable private keys on developer workstations. Use a separate CI-only certificate or HSM-backed key for build pipelines.

Sign with a timestamp so signatures survive cert renewal

Without timestamping, your signature becomes invalid when the code-signing certificate expires or is renewed. A timestamp server applies a countersignature proving the file was signed while the certificate was valid. The OS can continue to validate your signature even after renewal or expiration.

Signing with timestamp and SHA-256

The parameter name for the hash algorithm differs across PowerShell versions. Use a small compatibility wrapper:

$ts = 'http://timestamp.digicert.com'  # Alternatives: http://timestamp.globalsign.com/?signature=sha2, http://timestamp.sectigo.com
$signParams = @{ FilePath = '.\demo.ps1'; Certificate = $cert; TimestampServer = $ts }
if (Get-Command Set-AuthenticodeSignature -ParameterName HashAlgorithm -ErrorAction SilentlyContinue) {
  $signParams.HashAlgorithm = 'SHA256'
} elseif (Get-Command Set-AuthenticodeSignature -ParameterName DigestAlgorithm -ErrorAction SilentlyContinue) {
  $signParams.DigestAlgorithm = 'SHA256'
}
# Include the chain to help offline verification
if (Get-Command Set-AuthenticodeSignature -ParameterName IncludeChain -ErrorAction SilentlyContinue) {
  $signParams.IncludeChain = 'All'
}
$sig = Set-AuthenticodeSignature @signParams

Best practices:

  • Use a reputable timestamp server and keep a fallback list. Make signing resilient to intermittent network issues.
  • Include the chain (if available) to improve portability and offline verification.
  • Sign last: formatting tools or version stamps that run after signing will invalidate the signature.

Verify status before release and in CI

Quick local verification

Get-AuthenticodeSignature -FilePath .\demo.ps1 | Select-Object Status, StatusMessage, TimeStamperCertificate, @{n='Thumbprint';e={$_.SignerCertificate.Thumbprint}}

You should see Status = Valid. If not, check the trust chain, timestamp reachability, and whether the file changed after signing.

Verify entire repos and fail builds

Add a repo script to validate all PowerShell assets (.ps1, .psm1, .psd1):

# scripts\verify-signatures.ps1
$paths = Get-ChildItem -Recurse -Include *.ps1,*.psm1,*.psd1 -File
$failures = @()
foreach ($p in $paths) {
  $s = Get-AuthenticodeSignature -FilePath $p.FullName
  if ($s.Status -ne 'Valid') {
    $failures += [pscustomobject]@{
      File = $p.FullName
      Status = $s.Status
      Message = $s.StatusMessage
      Signer = $s.SignerCertificate.Subject
    }
  }
}
if ($failures.Count) {
  Write-Host "Signature validation failed for:" -ForegroundColor Red
  $failures | Format-Table -AutoSize
  exit 1
}
Write-Host "All signatures are valid." -ForegroundColor Green

GitHub Actions example (sign on release, verify on PR)

name: signatures
on:
  pull_request:
  release:
    types: [created]
jobs:
  verify:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Verify signatures
        shell: pwsh
        run: |
          ./scripts/verify-signatures.ps1
  sign-on-release:
    if: github.event_name == 'release'
    runs-on: windows-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - name: Import code-signing cert
        shell: pwsh
        run: |
          $pfx = [Convert]::FromBase64String('${{ secrets.CODESIGN_PFX_B64 }}')
          [IO.File]::WriteAllBytes('codesign.pfx', $pfx)
          $sec = ConvertTo-SecureString '${{ secrets.CODESIGN_PFX_PASSWORD }}' -AsPlainText -Force
          Import-PfxCertificate -FilePath .\codesign.pfx -CertStoreLocation Cert:\CurrentUser\My -Password $sec | Out-Null
      - name: Sign scripts with timestamp
        shell: pwsh
        run: |
          $cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Sort-Object NotAfter -Descending | Select-Object -First 1
          $ts = 'http://timestamp.digicert.com'
          Get-ChildItem -Recurse -Include *.ps1,*.psm1,*.psd1 -File | ForEach-Object {
            Set-AuthenticodeSignature -FilePath $_.FullName -Certificate $cert -TimestampServer $ts -HashAlgorithm SHA256 | Out-Null
          }
      - name: Verify after signing
        shell: pwsh
        run: ./scripts/verify-signatures.ps1
      - name: Commit signed scripts back to release branch
        run: |
          git config user.name "ci-bot"
          git config user.email "ci@example"
          git add -A
          git commit -m "chore(signing): sign PowerShell assets for ${{ github.ref_name }}" || echo "No changes"
          git push

Operational tips and security best practices

  • Separate certs per environment: Use different subjects/keys for dev, staging, and production to prevent cross-environment misuse.
  • Key hygiene: Limit who can access PFX material; prefer non-exportable keys on workstations. Consider using an HSM or a secure key provider for build agents.
  • Run signing on Windows: Set-AuthenticodeSignature requires Windows certificate stores. Use Windows runners for signing steps even if the rest of the pipeline is cross-platform.
  • Sign modules and manifests: Sign .ps1, .psm1, and .psd1 so module loading and script execution remain consistent under policy.
  • Don’t mutate after signing: Avoid version stamping or formatting after signature; run those earlier and sign at the end.
  • Use AllSigned where practical: Combine with trusted publishers to reduce prompts while preventing unsigned execution.
  • Revocation and validity: Ensure build and target machines can reach CRL/OCSP endpoints for your CA, or embed chain information to improve offline behavior.
  • Rotation: Renew before expiry, keep timestamping enabled, and update the trust stores with the new public cert. Old releases remain valid due to timestamps.

What you get

  • Trusted scripts: Team members and CI/CD can verify authorship and integrity.
  • Predictable execution: Compatible with AllSigned/RemoteSigned and enterprise controls.
  • Secure distribution: Clear provenance reduces the risk of supply-chain surprises.
  • Longevity with timestamps: Signatures remain valid even after certificate renewal.

Further reading

Build trust into your PowerShell workflow. Read the PowerShell Advanced Cookbook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →