Self-Elevating PowerShell Scripts You Can Trust: Detect, Elevate, and Return Exit Codes
Few things derail automation faster than a permissions error halfway through a run. A trustworthy, self-elevating PowerShell script cleanly detects whether it has administrative rights, relaunches itself with elevation when needed, preserves arguments, and propagates the child process’s exit code back to the caller. The result: no silent failures, clear exits, and predictable behavior in terminals, scheduled jobs, and CI.
Why Self-Elevate?
Windows protects privileged operations behind User Account Control (UAC). That’s good for security, but it can surprise you when a script tries to:
- Change Windows services, drivers, or firewall rules
- Write to Program Files, System32, or HKLM registry hives
- Install software or configure system-wide settings
Instead of relying on users to right-click “Run as administrator” (and hoping they remember), make elevation deterministic from within the script. You’ll reduce support friction, make logs easier to reason about, and ensure CI/CD jobs get consistent results.
Detect, Elevate, and Propagate Exit Codes
1) Detect Windows and Admin Privileges
You should short-circuit elevation on non-Windows platforms and only elevate when the current token lacks admin. The following snippet works on Windows PowerShell 5.1 and PowerShell 7+:
# Place at the very top of any script that may require admin
$onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or ($IsWindows -eq $true)
if ($onWindows) {
$isAdmin = ([Security.Principal.WindowsPrincipal]
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
# Choose the right host and bitness
if ($PSVersionTable.PSEdition -eq 'Desktop') {
# Force 64-bit when available to avoid SysWOW64 redirection surprises
$exe = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
} else {
# Resolve the exact path to pwsh to avoid PATH hijacking
$exe = (Get-Command pwsh -ErrorAction Stop).Source
}
# Preserve the script path and all original arguments
$alist = @(
'-NoProfile',
'-ExecutionPolicy','Bypass',
'-File', ('"' + $PSCommandPath + '"')
) + $args
# Relaunch elevated, keep the current working directory
$proc = Start-Process -FilePath $exe -ArgumentList $alist -Verb RunAs -PassThru -WorkingDirectory (Get-Location)
# Wait and return the child process exit code to callers/CI
$proc.WaitForExit()
exit $proc.ExitCode
}
}
Key details this handles:
- Windows detection: Uses PSEdition/Desktop and $IsWindows (when available) for reliability across PS 5.1 and 7+.
- Correct host and bitness: Pins a full path to powershell.exe or resolves pwsh to avoid PATH hijacking and 32/64-bit surprises.
- Argument fidelity: Passes
$argsthrough unchanged; Start-Process will handle quoting for-ArgumentListarrays. - Exit code propagation: Waits for the elevated child and exits with its code, which makes CLI and CI runs predictable.
2) Minimal, Familiar Variant
If you prefer a compact form, this streamlined block gets the job done:
# Place at the top of a script that needs admin rights
if ($IsWindows) {
$isAdmin = ([Security.Principal.WindowsPrincipal]
[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
$exe = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'powershell.exe' } else { 'pwsh' }
$alist = @('-NoProfile','-ExecutionPolicy','Bypass','-File',"\"$PSCommandPath\"") + $args
$proc = Start-Process -FilePath $exe -ArgumentList $alist -Verb RunAs -PassThru -WorkingDirectory (Get-Location)
$proc.WaitForExit()
exit $proc.ExitCode
}
}
# Admin-required work goes here
# Example: Set-Service -Name 'Spooler' -StartupType Automatic
Use this when you control the runtime environment (e.g., company images) and value brevity over added hardening.
3) Validate with a Test Script
Quickly validate that elevation and exit code propagation behave as expected:
# test-elevate.ps1
$onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or ($IsWindows -eq $true)
if ($onWindows) {
$isAdmin = ([Security.Principal.WindowsPrincipal]
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
$exe = if ($PSVersionTable.PSEdition -eq 'Desktop') { 'powershell.exe' } else { 'pwsh' }
$alist = @('-NoProfile','-ExecutionPolicy','Bypass','-File', ('"' + $PSCommandPath + '"')) + $args
$p = Start-Process -FilePath $exe -ArgumentList $alist -Verb RunAs -PassThru -WorkingDirectory (Get-Location)
$p.WaitForExit(); exit $p.ExitCode
}
}
Write-Host "Elevated: $isAdmin" -ForegroundColor Green
# Simulate work and explicit result
exit 42
Run the script from a normal PowerShell prompt; you should see a UAC prompt. The parent process should exit with code 42, which you can verify using $LASTEXITCODE or your CI step result.
Hardening for Trust and Predictability
Prefer Signed Scripts and Limit Bypass
- Sign your scripts: If you control the environment, prefer a code-signing workflow and an execution policy of
AllSignedorRemoteSigned. - Use Bypass intentionally: Passing
-ExecutionPolicy Bypassensures your already-trusted script can relaunch itself even under stricter user policies. If your scripts are signed and the environment is governed, you can omit Bypass.
Pin Executables and Avoid PATH Hijacking
- Resolve fully qualified paths: Use
(Get-Command pwsh).Sourceor a fully-qualified path topowershell.exein System32 instead of relying on PATH. - Bitness consistency: Prefer 64-bit
powershell.exeon 64-bit Windows to avoid WOW64 redirection issues when touching System32 or registry hives.
Preserve Working Directory and Arguments
- Working directory: Pass
-WorkingDirectory (Get-Location)so relative paths still work after elevation. - Arguments: Build
-ArgumentListas a string array; PowerShell will handle quoting. If you manually craft a single string, be meticulous with quotes and escaping.
Make Failures Loud and Useful
- Stop on errors: At the top of your script, set
$ErrorActionPreference = 'Stop'to prevent partial success. - Emit clear exit codes: Use
exit 0for success and non-zero for failures. Wrap critical sections intry/catchand exit deterministically. - Log everything: Consider
Start-Transcriptnear the start (after elevation) for traceability.
Network Drives and UNC Paths
- Mapped drives: Elevated tokens often don’t inherit user-mapped drives. Prefer UNC paths inside elevated scripts (e.g.,
\\server\share\path). - Working directory: If your script lives on a share, pass the UNC path explicitly. Alternatively, enable Linked Connections (GPO/registry) if that’s acceptable in your environment.
Real-World Patterns and Examples
Admin-Only Tasks You Can Automate Safely
- Service configuration: Ensure key services start automatically, restart on failure, or run under defined accounts.
- Firewall rules: Open necessary ports for dev tools or self-hosted agents.
- System configuration: Edit hosts file, tune TCP parameters, or update HKLM-based registry settings.
- Software bootstrap: Install system dependencies and set machine-wide environment variables.
Pattern: Ensure-Elevated, Then Work
# ensure-elevated-and-configure.ps1
$ErrorActionPreference = 'Stop'
# Self-elevate (block from earlier goes here)
$onWindows = ($PSVersionTable.PSEdition -eq 'Desktop') -or ($IsWindows -eq $true)
if ($onWindows) {
$isAdmin = ([Security.Principal.WindowsPrincipal]
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
$exe = if ($PSVersionTable.PSEdition -eq 'Desktop') {
Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
} else { (Get-Command pwsh).Source }
$alist = @('-NoProfile','-ExecutionPolicy','Bypass','-File', ('"' + $PSCommandPath + '"')) + $args
$p = Start-Process -FilePath $exe -ArgumentList $alist -Verb RunAs -PassThru -WorkingDirectory (Get-Location)
$p.WaitForExit(); exit $p.ExitCode
}
}
# Admin-required work starts here
try {
Write-Host 'Configuring Spooler service...' -ForegroundColor Cyan
Set-Service -Name 'Spooler' -StartupType Automatic
Start-Service -Name 'Spooler'
Write-Host 'Opening inbound firewall rule for app on TCP/8080' -ForegroundColor Cyan
if (-not (Get-NetFirewallRule -DisplayName 'My App 8080' -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -DisplayName 'My App 8080' -Direction Inbound -Action Allow -Protocol TCP -LocalPort 8080 | Out-Null
}
Write-Host 'All done.' -ForegroundColor Green
exit 0
} catch {
Write-Error $_
exit 1
}
CI/CD and Non-Interactive Sessions
- Interactive UAC:
-Verb RunAstriggers a UAC prompt. Non-interactive agents typically run with an already elevated token or as a service; in those cases, the self-elevation block will detect admin and skip relaunching. - Need elevation without prompts: Create a scheduled task that runs with highest privileges and trigger it from your pipeline, or run the agent under an account with the required rights.
Troubleshooting
- No output captured by parent: When you relaunch elevated, the new process has its own console. If you need to capture output programmatically, redirect to a log file or use remoting (Invoke-Command) and collect results.
- Script on network share fails after elevation: Use a UNC path for
-Fileand for any subsequent file operations inside the elevated context. - Access denied even when elevated: Some resources require additional privileges or group policy settings. Validate ACLs and, for registry/filesystem, check WOW64 redirection and use the correct provider view.
By making elevation explicit and reliable, you eliminate a whole class of “works on my machine” failures. Add a self-elevating preamble to your admin-touching scripts, propagate exit codes, and you’ll gain predictable, repeatable behavior across developer machines and pipelines alike.