PowerShell standards
Criteria verified as of August 2026. Re-verify on the web before committing to anything (§8).
1. Scope and triggers
Applies to all PowerShell code: automation scripts, publishable modules, advanced functions,
PowerShell steps in pipelines and their packaging, signing and distribution.
Triggers: .ps1, .psm1, .psd1, .ps1xml, .pssc, *.Tests.ps1, PSScriptAnalyzerSettings.psd1,
pwsh/powershell.exe, Install-PSResource, Invoke-Pester, Enter-PSSession,
New-PSSessionConfigurationFile.
It sets criteria (what to use, what is vetoed, what to verify), not tutorials.
Not applicable: see
windows-server-ad-standards(what is being administered is theirs: forest, domain, OU, GPO, Kerberos/NTLM, gMSA/dMSA, Tier 0, PAW, Windows Server roles. Here only how the script is written that calls theActiveDirectorymodule. Cut-off rule: if the directory takes the decision, it is theirs; if the code takes it, it is this skill's. Forbidden to duplicate AD criteria here.).bash-linux-scripting-standards(reciprocal boundary): POSIX/bash shell is theirs, PowerShell is this skill's. Choice criterion on cross-platform: if the target is a Linux host, its binaries and its text through pipes, write it in bash; if the target is Windows, an API returning objects (.NET, Graph, Az, Exchange, VMware) or you need to manipulate structures with properties, PowerShell. Their escape threshold still applies here: past the point where the script is an application, it goes topython-standards/go-standards, not to an 800-line.ps1.dotnet-standards(PowerShell runs on .NET, and that does not make it .NET): if the problem calls for an application — service, API, worker, distributable binary, measured performance — it is .NET; if it calls for administrative automation — orchestrating cmdlets, touching systems, packaging a module — it is PowerShell. Binary cmdlets in C# and the.csprojthat compiles them belong there; the module manifest and contract belong here.iac-standards(Terraform/OpenTofu and Ansible: if Ansible or the provider does it idempotently, you do not write a script; PowerShell DSC andInvoke-DscResourceare decided there),cicd-standards(the pipeline running the §4 gates, its OIDC and action pinning),secrets-management-standards(owner of the secrets manager choice: Vault/KMS/cloud manager, rotation and policy. Here only how the script consumes the secret without leaking it, andSecretManagementas a façade),identity-access-management-standards(IdP design, OAuth 2.1/OIDC, PAM/JIT; here only how the script authenticates without a static secret),appsec-standards(methodology and agnostic vulnerability classes; here PowerShell's concrete sinks:Invoke-Expression, deserialisation, argument injection),offensive-security-standards(authorised pentest/red team, with written scope. PowerShell is a common offensive tool and this skill is strictly defensive: see the hard prohibition in §7 — no AMSI bypasses, obfuscation, downloaders or logging evasion are written here).
2. Toolchain and default decisions
Verify the latest version on the web before fixing it in a real project (§8).
| Piece | Choice | Status 2026-08 | Reason |
|---|---|---|---|
| Target runtime | PowerShell 7.6 (LTS) | GA 2026-03-18, end of support 2028-11-14, on .NET 10 | It is the current LTS and the default for all greenfield |
| Runtime to abandon | PowerShell 7.4 (LTS) and 7.5 | both die on 2026-11-10 | Migrate now; there is no margin left |
| Windows PowerShell 5.1 | Compatibility only, never a target | An OS component, supported through the Windows lifecycle, not PowerShell's; still the default shell of Windows Server 2025 | Frozen in functionality: it gets no features, only servicing. Writing for 5.1 is writing for a runtime that is dead while walking |
| Backwards compatibility | Declare it, do not assume it: #Requires -Version 7.4 and CompatiblePSEditions in the manifest |
— | Windows PowerShell 2.0 was removed from Windows Server 2025 in the Sept-2025 update; the pattern repeats |
| Linter (gate) | PSScriptAnalyzer 1.25.x (MIT) | latest release 2026-03 | The ecosystem's only standard style/security gate |
| Formatting | PSScriptAnalyzer's Invoke-Formatter with a versioned PSScriptAnalyzerSettings.psd1 |
— | There is no black/ruff format in PowerShell: consistency is fixed by settings, not by taste |
| Tests | Pester 6.x (Apache-2.0) | 6.0.0 GA 2026-07-07, 6.0.1 current; v5 moves to maintenance (critical bugs and security only) | v6 runs on 5.1 and 7.4+; a new assertion family, profiler-based coverage, experimental parallel runner |
| Module manager | Microsoft.PowerShell.PSResourceGet 1.2.x (MIT) — Install-PSResource, Save-PSResource |
Inbox since PowerShell 7.4 | Replaces PowerShellGet 2.x: faster, with -TrustRepository and explicit verification |
| PowerShellGet | Only as a compatibility layer (v3 maps v2 syntax onto PSResourceGet) or legacy v2.2.5 | 7.4 did not ship the compatibility layer; both modules coexist | New code: *-PSResource cmdlets. Verify which version your runtime ships before assuming |
| Secrets in scripts | Microsoft.PowerShell.SecretManagement + the real manager's extension (Vault, Key Vault, KeePass) | verify version (§8) | The local SecretStore only for a workstation or single-node CI; never as a corporate vault |
| Editor/LSP | The VS Code PowerShell extension (it uses PSScriptAnalyzer underneath) | — | The gate that counts is CI, not the editor |
Target version rule: a module declares one compatibility surface and tests it. Supporting 5.1 and 7.x at once is a decision with a cost (double CI matrix, divergent .NET APIs, no modern operators): it is taken with an ADR, not by inertia.
3. Structure and conventions
- Command naming:
Verb-Noun, a verb from the approved list (Get-Verb) and a singular noun, with a module prefix to avoid collisions (Get-AcmeUser, notGet-Users). An unapproved verb is a PSScriptAnalyzer warning and breaks discovery viaGet-Command -Verb. - Casing:
PascalCasefor functions, parameters and properties;camelCasefor local variables; constants inPascalCase. Four spaces, never tabs. - Aliases FORBIDDEN in a script or module (
?,%,ls,cat,select,where,gci,curl/wgetas aliases ofInvoke-WebRequest): interactive console only. Parameters always by full name. - Module layout:
MyModule/ MyModule.psd1 # manifest: single source of truth for version and contract MyModule.psm1 # dot-source of Public/Private + Export-ModuleMember Public/Get-Thing.ps1 # one public function per file, same name Private/ConvertTo-X.ps1 Tests/Get-Thing.Tests.ps1 en-US/ # external help (MAML) if applicable - A
.psd1manifest is mandatory and complete:ModuleVersion(SemVer),RootModule,GUID,PowerShellVersion,CompatiblePSEditions,RequiredModuleswith versions, andFunctionsToExport,CmdletsToExport,AliasesToExportandVariablesToExportenumerated explicitly — never'*': the wildcard destroys command-discovery performance and leaks internal API. - Every public function is an advanced function:
[CmdletBinding()],begin/process/endblocks, typed parameters, and a declared[OutputType()]. - Parameters: explicit typing always;
[Parameter(Mandatory)]on what is required (neverRead-Hostto ask for it);ValidateSet,ValidateRange,ValidatePattern,ValidateNotNullOrEmptyat the edge — validation goes in the attribute, not in anifinside the body;ParameterSetNameinstead of mutually exclusive flags checked by hand. - Pipeline:
ValueFromPipeline/ValueFromPipelineByPropertyNameon the entity parameter, and the logic inprocess, not inend. A function that accepts pipeline input and processes everything inendis a bug. SupportsShouldProcessmandatory in every function that changes state:[CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]+if ($PSCmdlet.ShouldProcess($target, $action)).-WhatIfmust be real and propagate to internal calls. Destructive withoutShouldProcess= veto.- Output = objects, never text: emit
[pscustomobject]with stable properties (or aclass/type with a.ps1xmlformat file).Write-Hostonly for interaction with a human at the console; never as a data or log channel. Formatting (Format-Table,Out-String) is the end consumer's business, never that of a function someone else will consume. - Streams with a purpose:
Write-Outputfor data,Write-Verbosefor optional tracing,Write-Debugfor diagnostics,Write-Warningfor a recoverable anomaly,Write-Errorfor a non-terminating error,Write-Informationfor a structured message. Areturnthat returns a formatted string instead of an object is debt. Set-StrictMode -Version Latestas the default, on the first line of the module or script, alongside$ErrorActionPreference = 'Stop'. Without strict mode, a misspelt property returns$nullsilently and the script continues: it is the language's most expensive class of bug.- Paths:
Join-Path,$PSScriptRoot,[System.IO.Path]; never concatenation with\(it breaks on Linux/macOS). No implicitcd: cmdlets accept-Path. #Requiresin scripts:-Version,-Modules,-RunAsAdministrator— declare the precondition, do not discover it halfway through execution.- Comment-based help on every public function:
.SYNOPSIS,.PARAMETER,.EXAMPLE,.OUTPUTS. It is public API, not optional documentation.
4. Quality: lint, analysis and tests
- PSScriptAnalyzer as a gate that breaks the build:
Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1 -Severity Error,Warning, and fail the job if there are findings. The settings file is versioned; suppressions go with[Diagnostics.CodeAnalysis.SuppressMessageAttribute]with a specific rule and a writtenJustification— a global suppression or a massExcludeRulesis a veto. Rules that are never suppressed:PSAvoidUsingPlainTextForPassword,PSAvoidUsingConvertToSecureStringWithPlainText,PSUsePSCredentialType,PSAvoidUsingInvokeExpression,PSUseShouldProcessForStateChangingFunctions,PSAvoidUsingCmdletAliases. - Compatibility as an automated check, not as a belief: the rules
PSUseCompatibleCmdlets/PSUseCompatibleSyntax/PSUseCompatibleCommandsconfigured with the profiles of the platforms actually supported. - Pester 6 for every module with more than one function:
Describe/Context/Itstructure, one reason to fail per test, AAA. A*.Tests.ps1file beside the module, configuration via aNew-PesterConfigurationobject (never loose parameters scattered through CI).- Cover the happy path, edges and errors: invalid parameters, empty pipeline input,
non-existent object, permission denied, timeout,
-WhatIf(which must not touch anything). Mockat the boundaries (cmdlets that touch systems:Invoke-RestMethod,Get-ADUser,Set-Content), never of the code under test. Verification withShould -Invoke/Should-Invoke.- v4 → v5 break (still alive in legacy repos): in v4 the variables and code inside
Describeran on the fly; v5 split execution into Discovery and Run, so loose code insideDescribe/Contextruns in Discovery and setup must go inBeforeAll/BeforeEach;-TestCasesis resolved in Discovery. Migrating from v4 is not changing the version: it is rewriting the setup. - v5 → v6 break (verified in the official migration guide, §8): discovery and execution become
per file (this enables the parallel runner; each file must bring its own discovery setup);
Assert-MockCalledandAssert-VerifiableMockremoved; an empty or$null-ForEach/-TestCasesnow fails unless-AllowNullOrEmptyForEach; duplicateBeforeAll/AfterAllblocks in the same scope forbidden; profiler-based coverage by default and theCoverageGuttersoutput retired. Support limited to Windows PowerShell 5.1 and PowerShell 7.4+. - Zero
Start-Sleepas synchronisation in tests. Flaky = fixed or deleted. Every bug leaves a regression test.
- CI gates, in increasing order of cost (all of them block the merge):
- Parsing/syntax (
[System.Management.Automation.Language.Parser]::ParseFile) andTest-ModuleManifest. Invoke-ScriptAnalyzerwith the repo's settings.Invoke-Pesterunit tests with coverage.- Integration tests against a real system or container, in a platform matrix (Windows + Linux if the module is declared cross-platform; and 5.1 only if you really support it).
- Authenticode signing of the artifact and publication.
- Parsing/syntax (
- The same command locally and in CI. If CI does something that cannot be reproduced locally, that is a pipeline bug.
- Coverage: a signal, not a goal. A threshold agreed by the team, main always green.
5. Security
Errors (the basis of everything else):
$ErrorActionPreference = 'Stop'at the start. PowerShell distinguishes terminating errors (they abort the pipeline and are catchable withtry/catch) from non-terminating ones (they go to$Errorand the script carries on): a failedRemove-Itemwithout-ErrorAction Stopthrows no exception and the script continues believing it deleted something.try/catch/finallywith a typedcatch(catch [System.IO.FileNotFoundException]) before the generic one.finallyto release resources (sessions, files,Dispose), always.throwto abort your own flow;Write-Errorto report a per-item failure without aborting the batch (with-ErrorAction Stopat the call site if the consumer wants it to abort).$PSCmdlet.ThrowTerminatingError()in advanced functions when the error belongs to the cmdlet, not to the item.- FORBIDDEN:
-ErrorAction SilentlyContinueto hide a failure that has not been understood, and an emptycatch {}. Silence only with a comment explaining why that error is expected.
Execution and policy:
Set-ExecutionPolicyis NOT a security control. It is a barrier against accidental execution, documented as such, and it is trivially bypassed by design (-EncodedCommand, piping via stdin, copying and pasting the content). Treating it as a control in a design, an audit report or a risk exception is a technical error.RemoteSignedis the reasonable default on servers; permanentBypass/Unrestrictedare vetoed.- The real control is App Control for Business (WDAC) with a signed policy: under it, only
authorised code runs in
FullLanguageand everything else falls intoConstrainedLanguage, where arbitrary access to .NET and COM disappears. AppLocker is not formally deprecated as of 2026-02 but Microsoft states that it does not meet the MSRC's security feature servicing criteria, whereas App Control does: do not use it as the sole CLM mechanism. Verify the status before fixing it (§8). - Authenticode signing of every script and module that is distributed, with a code-signing
certificate from a CA (internal or public) and the key in an HSM/non-exportable store; time
stamping (
-TimestampServer) is mandatory so the signature survives certificate expiry. Verify withGet-AuthenticodeSignatureat the destination, do not trust the origin.
Logging and detection (they are configured, not avoided):
- Module Logging, Script Block Logging (it records the real block, including deobfuscation)
and transcription (
Start-Transcript/GPO, to a central write-only share) enabled on all managed hosts, with the events forwarded to the SIEM. AMSI enabled: PowerShell submits every block to the antimalware before executing it. - Consequence for whoever writes: everything you run is logged, including the secrets you pass on
the command line. Never a secret as an argument (
ps, history, script block logs, transcription,Get-History).
Credentials:
[PSCredential]and[SecureString]as in-memory transport types; the credential parameter is declared[PSCredential]with[System.Management.Automation.Credential()].- FORBIDDEN:
ConvertTo-SecureString -AsPlainText -Forcewith a secret written in the file, andConvertFrom-SecureStringto a file as a "store" (on Windows it depends on the user's DPAPI; on Linux and macOS it encrypts nothing, it is obfuscated plaintext).SecureStringis not a protection control: documented by Microsoft as not recommended for new cross-platform code. It reduces accidental exposure, it is not encryption. - The secret is obtained at run time from the manager (
Get-Secretfrom SecretManagement over the Vault/Key Vault extension) or from a federated identity (managed identity, CI runner OIDC). Prefer having no secret at all: the choice of manager and the rotation policy belong tosecrets-management-standards. - Never secrets in
.psd1, inPrivateData, in the repo, in persisted environment variables, or in logs.
Injection and untrusted input:
Invoke-Expressionis vetoed with no practical exception: it is PowerShell'sevaland the number one injection vector. Alternatives: a direct call, splatting (@params),& $command @args, a[scriptblock]built in your own code.- Do not build a command line by concatenating user input for
Start-Process/cmd /c: pass arguments as an array (-ArgumentList @(...)). - SQL from PowerShell: parameterised queries only (
Invoke-Sqlcmd -Variable, orSqlCommandwithParameters.AddWithValue). Concatenation is a veto — seesql-standards. - Deserialisation:
Import-Clixmlover untrusted data is vetoed (it reconstructs types and is an execution vector). For external data,ConvertFrom-Json(and-AsHashtablewhere applicable) with subsequent schema validation. - Downloading code:
Invoke-WebRequest/Invoke-RestMethodwith TLS verified; FORBIDDEN-SkipCertificateCheckand any manipulation ofServerCertificateValidationCallbackoutside a lab. Neveriwr ... | iex.
Remoting and surface:
- PowerShell Remoting over SSH is the default in new and cross-platform scenarios (key-based authentication, without WinRM's surface). WinRM only in a domain, with Kerberos (never Basic nor cleartext credentials), HTTPS, and firewall-restricted to the administration origins.
- JEA (Just Enough Administration) for every operational delegation: a session configuration
(
.pssc+RoleCapabilities.psrc) withSessionType = 'RestrictedRemoteServer', running under a virtual account or gMSA, with an allowlist of cmdlets and parameters. The operator does not need to be an admin to restart a service. JEA without transcription enabled is incomplete. - Minimum surface: do not enable PSRemoting on hosts that do not need it;
Enable-PSRemotingis not part of a base template by default.
6. Performance and operability
- The pipeline is the mechanism, not an ornament: filter at the source (
Get-ChildItem -Filter,Get-ADUser -Filter, the server's-Query) before pulling everything and filtering withWhere-Object. Pulling 100k objects to discard 99k is the most common performance antipattern. - Never
$array += $itemin a loop: it recreates the whole array on every iteration (O(n²)). Use[System.Collections.Generic.List[T]], or let the pipeline collect the output. - Strings:
-joinorStringBuilder, not$s += "..."in a loop. - Concurrency:
ForEach-Object -Parallel -ThrottleLimit(7.x) for I/O;Start-ThreadJobfor work with controlled shared state;Start-Job(a full process) only when isolation is needed. Beware$using:and the cost of serialisation — measure before parallelising. - Explicit timeouts on every network call (
Invoke-RestMethod -TimeoutSec,-OperationTimeoutSeconds,-ConnectionTimeoutSeconds) and on remote sessions: the default may be too permissive or too aggressive, but it must never be implicit. - Retries with backoff on idempotent operations (
-MaximumRetryCount/-RetryIntervalSecinInvoke-RestMethod; your own with jitter elsewhere). Retrying a non-idempotentPOSTis duplicating data. - Idempotency: an automation script runs twice with no harm, or it is not an automation script.
Check state before changing;
-WhatIfas a real dry-run mode. - Exit codes:
exit 0on success and non-zero on failure, always — CI and schedulers depend on it.$LASTEXITCODEis checked after calling native binaries ($?is not enough); in 7.4+ there is$PSNativeCommandUseErrorActionPreferenceto integrate native exit codes withErrorAction: verify your version's default before relying on it (§8). - Structured logging: emit objects and let the consumer decide, or
Write-Informationwith an object; in production, JSON output (ConvertTo-Json -Depthexplicit — the default truncates to 2 levels and has bitten everyone). A correlation id on long-running operations. Never personal data or secrets in the log. - Progress and cancellation:
Write-Progresson long interactive operations (and disableable in CI with$ProgressPreference = 'SilentlyContinue', which also speeds upInvoke-WebRequestnoticeably); afinallythat cleans up sessions and temporary files onCtrl+C. - Heavy modules: import what you use (
Import-Module -Name X -Function Y); autoloading withFunctionsToExport = '*'degrades every session start.
7. Long-term sustainability
- Cadence: follow PowerShell's LTS (which follows .NET's) and plan the migration before the end-of-support date, not after. As of 2026-08 the critical clock is 7.4 and 7.5 dying on 2026-11-10.
- Your own modules versioned with SemVer in the manifest, with a
CHANGELOGand publication from CI (neverPublish-PSResourcefrom a laptop with a personal API key). Deprecate with a warning + a window. - PSGallery dependencies: pin versions (
RequiredVersion/MinimumVersioninRequiredModules), register the repository as-Trustedexplicitly and consciously, and prefer an internal mirrored feed (Azure Artifacts, ProGet, Nexus) in a corporate environment: the Gallery is a public registry with no strong curation and module-name typosquatting is real. A module with no release in >18 months is reviewed or replaced. - 5.1 → 7.x migration: inventory the modules that only exist on Windows PowerShell and test
Import-Module -UseWindowsPowerShellas a temporary bridge with a cost (a proxy over local remoting, deserialised objects without methods), never as the final architecture. - Conscious debt: a shortcut = a TODO with a reason and a linked issue.
Prohibition list (veto):
- ❌
Invoke-Expression(andiex) over anything that is not your own literal. Neveriwr | iex. - ❌ Aliases in scripts and modules; positional parameters in non-trivial calls.
- ❌ A script or module without
Set-StrictMode -Version Latestand without$ErrorActionPreference = 'Stop'. - ❌ An empty
catch {},-ErrorAction SilentlyContinueas a way of ignoring a failure that has not been understood. - ❌ A function that changes state without
SupportsShouldProcess/ShouldProcess, or with a decorative-WhatIf. - ❌
Write-Hostas a data or log channel. Returning formatted strings instead of objects. - ❌
ConvertTo-SecureString -AsPlainText -Forcewith the secret in the file; secrets in.psd1, in command-line parameters, in the history or in persisted environment variables. - ❌
-SkipCertificateCheck, disabling TLS validation, or setting[Net.ServicePointManager]::SecurityProtocolto obsolete protocols. - ❌
Import-Clixmlover external data.ConvertFrom-Jsonwithout validating what was deserialised. - ❌
FunctionsToExport = '*'in a published manifest. - ❌
$array += ...inside a loop over non-trivial collections. - ❌ Persistent
Set-ExecutionPolicy Bypass, or presenting the execution policy as a security control. - ❌ WinRM with Basic authentication, or over HTTP outside an isolated lab.
- ❌ Evasion techniques: AMSI bypass or patching, disabling or tampering with Script Block Logging
and transcription, script obfuscation, downloaders (stagers) and in-memory loaders. This skill
is defensive: here those controls are configured and verified, not circumvented. All offensive
work — including the legitimate kind — lives in
offensive-security-standards, with written scope and authorisation, and is not documented here. - ❌ Writing new code exclusively targeting Windows PowerShell 5.1 without an ADR justifying it.
8. Mandatory web verification
Before fixing versions or claims in a project, verify online (WebSearch/WebFetch), preferring
api.github.com/repos/OWNER/REPO/releases/latest or the /releases.atom feeds over the releases
HTML:
- PowerShell lifecycle on the official support lifecycle page: is 7.6 still the LTS? Has 7.7 (on .NET 11) shipped and on what date? Confirm that 7.4/7.5 are already dead (scheduled 2026-11-10).
- Status of Windows PowerShell 5.1 and of the current Windows Server (as of 2026-08, Windows Server 2025 is the latest version; there is a vNext in preview with no product name): any formal deprecation announcement, or a change of default shell?
- Pester: is 6.1 stable yet? Re-read the official guide
pester.dev/docs/migrations/v5-to-v6before migrating; as of 2026-08 v5 is in maintenance (critical bugs and security only). - PSScriptAnalyzer (1.25.0 as of 2026-03, MIT): a new version, new rules, default severity changes that break the gate? Pin the exact version in CI.
- PSResourceGet vs PowerShellGet: which version your runtime ships (
Get-Module -ListAvailable), and whether the PowerShellGet v3 compatibility layer is stable and inbox yet. Do not assume it. - SecretManagement / SecretStore: current version and maintenance status — not verified as of Aug-2026, confirm before fixing a version.
- App Control for Business vs AppLocker: check the official Windows deprecated features list
before asserting AppLocker's status (as of 2026-02 it did not appear as deprecated, but
Microsoft states it does not meet the MSRC's security servicing criteria). Also verify the status
of
WldpCanExecuteFileand the CLM behaviour in the Windows build in use. - JEA: status and documented limitations in the target PowerShell version — not verified in detail as of Aug-2026.
- Defaults that change with the version:
$PSNativeCommandUseErrorActionPreference, experimental features (Get-ExperimentalFeature), and runtime CVEs (GitHub Advisories / MSRC) before fixing a version.
If the web contradicts this document, the web wins — flag the discrepancy.