Hardening PowerShell — Logging, AMSI, Language Modes, and JEA

· Updated: · · PowerShell, Security, Windows, Logging, Auditing, Information Systems, Privilege Management, Operational Improvement

Revision history (first version, published Jul 25, 2026)
First published
Cite this article(DOI (registered archive): 10.5281/zenodo.22170777)

The DOIs below refer to previously archived versions and may not match the current text. Use this page’s URL to reference the current text.

Go Komura (2026). Hardening PowerShell — Logging, AMSI, Language Modes, and JEA. KomuraSoft LLC. https://comcomponent.com/en/blog/powershell-security-hardening-jea/

DOI (registered archive)
10.5281/zenodo.22170777
DOI (last registered version)
10.5281/zenodo.22170778

After a steady stream of reports that “PowerShell was abused during the intrusion,” some organizations try to ban the use of PowerShell outright inside the company. That does not pay off, either in effectiveness or in business impact. PowerShell is the management foundation of Windows itself, and stopping it stops your operational automation. Meanwhile, an attacker can do the equivalent by other means.

The realistic policy is visibility and restriction, not prohibition. First record what was executed, and then narrow the paths that bypass inspection, the code that can run, and the privileges you hand to users.

PowerShell 5.0 and later has the features for this. Script block logging records even obfuscated code in its expanded form, and AMSI (Antimalware Scan Interface) has your antivirus product inspect the content before execution. Language modes restrict the syntax that can be used, and JEA delegates only the operations that are needed. The approach in this article is to combine these distinct roles and reach a state you can audit without stopping the business.

This article summarizes the settings to apply in order to keep using PowerShell safely in an internal Windows environment, in order of effectiveness. Execution policy and signing are covered in “PowerShell Execution Policy and Script Signing,” so this article picks up from there.

How to Read This Article, by Goal

What you want to know or are struggling with Where to read What to check there
Which measure to start with Chapter 1: The conclusion, Chapter 2: The big picture Do visibility first, then narrow privileges and execution scope in stages
You want a record of what was run, or you cannot find 4104 Chapter 3: Logging Where 5.1 and 7 are configured and where they record, checking in a new session, log capacity
You do not want the aggregated records deleted Chapter 3: Share permissions Protecting other people’s records, and the limit that overwriting your own cannot be prevented
Why disable the old engine when AMSI is there Chapter 4: Bypass paths The engine that supports inspection, and what to do about a PowerShell 2.0 that is still present
Whether AllSigned or language modes alone can protect you Chapter 5: Execution policy, Chapter 6: Language modes The value of signing, and the combination with application control
You want to delegate only a service restart to the help desk Chapter 7: JEA The difference between defining and registering on the server and connecting from the user side
You want to check for gaps before rollout Chapter 8: Checklist Whether recording, protection and restriction actually work, not just that the settings were applied

The code is not a procedure to be run wholesale against production. Paths, server names and group names are examples. Check where each setting goes and what privileges it needs, substitute your own values, and try it on a test machine. The log aggregation share is configured on the aggregation server, and JEA is registered on the server whose operations you delegate.

Target Environment and Prerequisites

Item Details
Target OS Windows 10/11 and Windows Server. AMSI requires Windows 10 or later1
Target versions Both Windows PowerShell 5.1 and PowerShell 7 are covered. The policy keys and the log destinations differ between 5.1 and 7, so on a machine that has both, configure both (Chapters 3 and 4)
Privileges required The logging settings and the disabling of PowerShell 2.0 in Chapters 3 and 4, and the JEA endpoint registration in Chapter 7, all require administrator privileges. In real operations you distribute these through Group Policy rather than entering them by hand on one machine at a time
Local or remote Chapters 3 through 6 are self-contained on the target machine or server. Only the JEA in Chapter 7 assumes that PowerShell remoting (WinRM) is enabled (see the beginning of Chapter 7)
Out of scope The details of execution policy and signing are covered in a separate article (PowerShell Execution Policy and Script Signing). This article only places them in context (Chapter 5)

1. The Bottom Line First

The top priority is visibility through logs. After that, layer on disabling the old engine and restricting privileges and execution scope to fit your business.

First, record what is executed and preserve it

Enable script block logging and record executed code as event ID 4104. Obfuscated code is retained in its expanded form as well. Use transcription, which includes input and output, alongside it, and review your log sizes. Left at the defaults, they wrap within a short period.2

Aggregate transcripts to a share that users cannot delete from. Note, however, that what the share ACL in this article prevents is access to other people’s records and deletion of your own. It does not prevent you from overwriting your own records. That limit, and how to move the records to a collection point users cannot touch, are explained in Chapter 3.

Next, leave no old path that bypasses inspection

With AMSI, scripts are handed to your antivirus product before execution. It is active on PowerShell 5.0 and later, on Windows 10 and later.1 The old PowerShell 2.0 engine, on the other hand, is reached by neither logging nor AMSI, so disable it if it is still present.3

Narrow privileges and execution scope, each with the feature that fits

The execution policy is not a security boundary, and it is not the core of your defense.4 Language modes are used as the result of configuring application control with WDAC/AppLocker. Setting them manually is bypassable and does not function as a security feature.5

If you want to hand over a specific operation, use JEA. Permit “only this parameter, of this command,” and keep operations running without handing out administrator privileges to users.6

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (24 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. What You Are Protecting — Visibility First, Restriction Second

Here we look separately at what each measure does and the order in which to introduce them. The following diagram shows the roles of delegation, restriction, inspection and recording; it does not mean you introduce them top to bottom. The priority of introduction is in the table below it.

Administrators, help desk, automation scripts(and the attacker who got in)[Delegate] JEA (Chapter 7)Hand over only permitted operations, without handing out admin rights[Restrict] Language modes + WDAC/AppLocker (Chapter 6)Narrow the code that can run and the syntax that can be used[Inspect] AMSI (Chapter 4)Hand the content to the antivirus product just before executionDisabling PowerShell 2.0 closes the bypass pathOperations in PowerShell[Record] Script block logging 4104 and transcription (Chapter 3)Retained with the obfuscation undone

Recording creates a state you can trace after the fact, delegation makes the privilege itself smaller, and inspection and restriction stop things before execution. The four are not substitutes for one another; they cover different layers. On top of that, there is a priority order for introducing them.

Stage What to do Effect
1. Visibility Script block logging, transcription You can tell what happened. After-the-fact investigation becomes possible
2. Basic neutralization Disabling PowerShell 2.0, staying on the latest version, confirming AMSI Closes the paths that bypass inspection
3. Limiting privileges Delegation with JEA, reducing administrator privileges Limits the blast radius
4. Restricting execution WDAC/AppLocker + constrained language mode Stops unapproved code from running at all

In many environments, doing 1 and 3 alone improves the situation dramatically. Step 4 has a high deployment cost, so it is an area to advance while verifying the impact on the business.

3. Enable Logging — 4104 Matters Most

Read this chapter in this order: choose which kind of log → configure both 5.1 and 7 → confirm that records actually appear → sort out capacity and the permissions on the destination. Before you point transcription at a share, check the permission design in the second half and prepare it.

3.1 Choose the log by what you want to retain

PowerShell gives you three kinds of logging to use for different purposes: module logging, script block logging and transcription. Keep straight what goes into events and what goes into files, and where each version records.2

Kind What is recorded Event ID
Module logging Details of pipeline execution for the specified modules 4103
Script block logging The text of the code that was executed (after de-obfuscation) 4104
Destination log 5.1 uses Microsoft-Windows-PowerShell/Operational, 7 uses PowerShellCore/Operational
Transcription Records session input and output to a text file — (file output)

3.2 Confirm where the setting goes before enabling it

Windows PowerShell 5.1 is configured under Group Policy at “Computer Configuration > Administrative Templates > Windows Components > Windows PowerShell.”2 PowerShell 7 is on the “Administrative Templates > PowerShell Core” side. The templates are separate too, so do not stop after configuring only the 5.1 side.7

Below is an example of setting this directly in the registry. For a normal in-house rollout you distribute it through Group Policy. The first half is script block logging and the second half is transcription, each configured for 5.1 and for 7. The share name for the output directory is an example; substitute the aggregation point you prepared earlier.

# Enable script block logging (requires administrator privileges; normally distributed by GPO)
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item -Path $key -Force | Out-Null
# -Type is a dynamic parameter added by the registry provider; it makes the value type (DWord) explicit
Set-ItemProperty -Path $key -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord

# PowerShell 7 (pwsh) reads a different policy key. Set both
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\PowerShellCore\ScriptBlockLogging'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableScriptBlockLogging' -Value 1 -Type DWord

# Enable transcription and aggregate to a write-only share
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableTranscripting'     -Value 1 -Type DWord
Set-ItemProperty -Path $key -Name 'OutputDirectory'         -Value '\\logsrv\pstranscripts$'
Set-ItemProperty -Path $key -Name 'EnableInvocationHeader'  -Value 1 -Type DWord

# Transcription also has a separate key on the PowerShell 7 side. Write the same values
$key = 'HKLM:\SOFTWARE\Policies\Microsoft\PowerShellCore\Transcription'
New-Item -Path $key -Force | Out-Null
Set-ItemProperty -Path $key -Name 'EnableTranscripting'     -Value 1 -Type DWord
Set-ItemProperty -Path $key -Name 'OutputDirectory'         -Value '\\logsrv\pstranscripts$'
Set-ItemProperty -Path $key -Name 'EnableInvocationHeader'  -Value 1 -Type DWord

In this example, 5.1 is configured under Windows\PowerShell and 7 under PowerShellCore. For both script block logging and transcription, the same values are written to both keys. If you distribute through GPO, check each template as well.

The GPO for PowerShell Core does offer the option of using the Windows PowerShell settings, but do not conclude that entering the 5.1 settings has enabled the 7 side too. Whichever configuration method you adopt, the point is to confirm that records appear in both runtimes.7

3.3 Confirm in a new session that records are being written

The value of script block logging is its resistance to obfuscation. Even a Base64-encoded command or code assembled through string concatenation is recorded in the form it was expanded to at execution time.2 Whether you can reconstruct “what was executed” during an incident investigation is decided by whether this setting is in place.

You check the records with Get-WinEvent (see “Investigating Event Logs in Practice with Get-WinEvent”).

# Check script block logs from the last day.
# Windows PowerShell (5.1) and PowerShell 7 write to different logs,
# so target both
Get-WinEvent -FilterHashtable @{
    LogName   = 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational'
    ID        = 4104
    StartTime = (Get-Date).AddDays(-1)
} -ErrorAction SilentlyContinue |
    Select-Object TimeCreated, LogName, @{ n='Script'; e={ $_.Message } } -First 20

To judge whether you succeeded in enabling it, use these three points.

  1. After applying the setting, open a new PowerShell session. Script block logging records from sessions started after it is enabled.2 Do not test in a window that is already open and conclude that “nothing comes out.”
  2. Run a recognizable command in a new session and see whether it comes back as 4104. If the query above returns an event containing the code fragment you just ran, it is working. If nothing comes back, use Get-ItemProperty to check the value of the policy key (EnableScriptBlockLogging is 1) and which key you wrote it to, the 5.1 side or the 7 side.
  3. Check that you are not looking at the wrong log. If you ran it with pwsh, the destination is PowerShellCore/Operational. Looking only at Microsoft-Windows-PowerShell/Operational and deciding “nothing is being recorded” is the most common misreading with this setting.

The query above specifies -ErrorAction SilentlyContinue. Do not take “nothing came back” on its own as proof that enabling failed or that nothing was executed. First separate out whether each log exists and which session you checked from. If the event provider is not registered on PowerShell 7, follow Microsoft’s guidance and register it with $PSHOME\RegisterManifest.ps1 from an elevated PowerShell 7 session.8

3.4 Set the log size, and verify the value after the change

Once you enable logging, always review the log size. At the default size it wraps within hours to days, and it is gone when you need it.

Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational' |
    Select-Object LogName, MaximumSizeInBytes, RecordCount
wevtutil sl Microsoft-Windows-PowerShell/Operational /ms:1073741824   # example of expanding to 1 GB
wevtutil sl PowerShellCore/Operational               /ms:1073741824   # do not forget the PowerShell 7 side

# Confirm that it took effect. Success is MaximumSizeInBytes showing 1073741824
Get-WinEvent -ListLog 'Microsoft-Windows-PowerShell/Operational', 'PowerShellCore/Operational' |
    Select-Object LogName, MaximumSizeInBytes

The code above is one set: check the current value → an example of changing it to 1 GB → check the value after the change. wevtutil sl prints nothing even on success, so compare MaximumSizeInBytes before and after rather than looking for output. If you are not running with administrator privileges the change is refused, so if the value does not change, suspect that first.

Make the transcript aggregation point a share you can write to but not delete from

First, decide what you can protect

The aggregation point here is not a share where anyone can freely read and write other people’s records. And “write-only” as used in the text and the code does not mean that overwriting your own record is forbidden too. The writer is different from the one behind JEA’s TranscriptDirectory, so do not apply the same permission design there unchanged.

The point about the transcript output directory is to make it a share users can write to but cannot delete from. If you keep it locally, it gets erased on a compromised machine.

Set both the share permissions and the NTFS permissions (the effective permission is the stricter of the two). The essential part is to give users only “create a file in the folder” and “read and write the files they created themselves,” and not to give delete (DE) or delete subfolders and files (DC).

Let us first be explicit about what this configuration protects and what it does not. It protects two things: “no one can touch anyone else’s records” and “you cannot delete your own records either.” What it does not protect is “overwriting your own record,” which cannot be closed with an ACL alone (the reason follows below). Even so, simply making it impossible for a hijacked account to erase other people’s evidence changes the triage picture substantially. If one machine falls, the records from the other machines survive.

Configure the share and the NTFS permissions on the log aggregation server

This code strips the existing permissions on the folder you specify and puts them back in. Do not apply it as is to a business share; try it on a test folder for logs. Substitute your own values for EXAMPLE\Domain Users, EXAMPLE\Log Admins and the storage path.

# Run this on the log aggregation server (requires administrator privileges)
$path = 'D:\PSTranscripts'
$null = New-Item -Path $path -ItemType Directory -Force

# Create the share. Users get Change (write) at most; only the log administrators manage it
New-SmbShare -Name 'pstranscripts$' -Path $path `
    -ChangeAccess 'EXAMPLE\Domain Users' -FullAccess 'EXAMPLE\Log Admins'

# Break inheritance from the parent folder (second argument $false = do not keep the inherited ACEs).
# If the CREATOR OWNER full control that comes down by inheritance is left in place,
# you end up with "you can delete the files you created," and the aggregation is pointless.
#
# However, what SetAccessRuleProtection removes is only "inherited ACEs";
# ACEs granted directly on that folder remain. New-Item -Force above
# succeeds on an existing folder too, so if change permissions were once
# granted directly to Domain Users or Everyone, that grant survives
# even if you run every line below it.
# Users can still overwrite and delete other people's transcripts.
# So we drop all direct grants first, and then put back only what is needed
$acl = Get-Acl -Path $path
$acl.SetAccessRuleProtection($true, $false)

foreach ($ace in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($ace) }

# Having cleared them, put the administrators and SYSTEM into the same $acl and apply once.
# Calling Set-Acl separately for "empty it" and "put it back" creates a moment when no one has access
foreach ($id in @('EXAMPLE\Log Admins', 'SYSTEM')) {
    $acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
        $id, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'))
}
Set-Acl -Path $path -AclObject $acl

# Give users only "create" on the folder. The key is not adding (OI), so that
# this grant is not inherited by the files underneath
#   WD=create files  AD=create folders  X=traverse folder  RA=read attributes
#   Adding (OI) would hand out "write data to an existing file" as well
icacls $path /grant 'EXAMPLE\Domain Users:(CI)(WD,AD,X,RA)'

# Let a writer read and write "only the files they created themselves."
# CREATOR OWNER is replaced by "that creator" when the file is created,
# so it does not apply to other people's files. DE (delete) is not granted
#   RD,WD,AD = read, write and append data          RA,WA  = read and write attributes
#   REA,WEA  = read and write extended attributes   RC     = read permissions
#   S        = synchronize. Included in GENERIC_READ/GENERIC_WRITE, so it cannot be dropped
# The obvious move is to narrow this to AD (append) alone, but then not one line of transcript is written (see below)
icacls $path /grant 'CREATOR OWNER:(OI)(IO)(RD,WD,AD,REA,WEA,RA,WA,RC,S)'

# This is the key part. The line above is not enough on its own.
# The user who created a file becomes the "owner" of that file.
# Windows implicitly grants the owner READ_CONTROL and WRITE_DAC, so
# even though neither WD nor DE was handed out, the owner can rewrite the DACL
# and grant themselves overwrite and delete rights. In other words, a hijacked account
# can erase its own evidence. When an OWNER RIGHTS ACE is present, the system
# ignores the implicit READ_CONTROL / WRITE_DAC for the owner
icacls $path /grant 'OWNER RIGHTS:(OI)(IO)(RA,REA)'

icacls $path        # check the result

Check 1: are old permissions left behind?

When you reuse an existing folder, always clean out the directly granted ACEs. What SetAccessRuleProtection($true, $false) removes is only inherited ACEs; ACEs attached directly to that folder stay as they are.9 New-Item -Force succeeds on an existing folder too, so if you reuse a folder with a history such as “we temporarily granted change permissions to Domain Users once,” then even after you narrow CREATOR OWNER later, that direct grant alone survives and users can overwrite and delete other people’s transcripts. That is why the code above drops everything once and puts it back. It is unnecessary if you create a new folder, but there is no harm in keeping it. After applying, look at the output of icacls $path with your own eyes and check that no unintended principals are listed.

Check 2: are file creation and writing to existing files kept separate?

This is where how you use (OI) decides whether the measure works. If you add (OI) to the users’ grant and hand out WD, that ACE is inherited by every file underneath, and anyone who knows the name can overwrite or truncate someone else’s transcript.9 That defeats the goal of “the records survive even after the machine or account is taken over.” As above, split it into create permission on the folder (not inherited) and read/write access through CREATOR OWNER to only the files you created yourself.

Check 3: can the owner grant themselves delete rights again?

And do not drop the OWNER RIGHTS line. The user who created a file becomes the owner of that file. Windows implicitly grants the owner READ_CONTROL and WRITE_DAC,10 so even without DE in an ACE, the owner can rewrite the DACL and grant themselves delete rights again. As long as “I can delete my own records” holds, narrowing CREATOR OWNER was pointless. When an ACE for OWNER RIGHTS (S-1-3-4) is in place, the system ignores the implicit READ_CONTROL / WRITE_DAC for the owner, and only then does “writable but not deletable” hold.10

Why not narrow it to AD (append) alone

This section is a supplement for readers who want to know why overwriting is allowed. The short answer is that if you do not satisfy the access rights Start-Transcript requests, you lose the recording itself before you get to protect it.

“Append-only” is decided by how the file is opened, not by the name of an ACL right

The reflex is to think “since I do not want overwrites, AD alone is enough for CREATOR OWNER.” Do that and not a single line of transcript survives.

In Windows, “append only” holds when the writer opens the file specifying FILE_APPEND_DATA on its own. The Microsoft reference defines FILE_APPEND_DATA as “(For local files, write operations will not overwrite existing data if this flag is specified without FILE_WRITE_DATA.)”11 In other words, append-only is a property of how the file is opened, not something you achieve by putting AD in an ACE.

Check which rights Start-Transcript requests

And the write handle in Start-Transcript is not opened append-only. The PowerShell implementation first opens with FileMode.OpenOrCreate + FileAccess.ReadWrite, and only if that fails does it reopen with FileMode.Append + FileAccess.Write.12 On the .NET side, FileAccess.Read/Write map to GENERIC_READ/GENERIC_WRITE respectively, and FileMode.Append is internally replaced with FileMode.OpenOrCreate before seeking to the end.13 There is no path that requests FILE_APPEND_DATA on its own.

FILE_GENERIC_WRITE includes FILE_WRITE_DATA, FILE_WRITE_ATTRIBUTES, FILE_WRITE_EA, READ_CONTROL and SYNCHRONIZE, and FILE_GENERIC_READ includes FILE_READ_DATA, FILE_READ_ATTRIBUTES, FILE_READ_EA, READ_CONTROL and SYNCHRONIZE.11 The access check looks at all of the requested rights, so this CreateFile is denied for a user who holds only AD,RA,REA. Even if the share side permits Change, it fails on the NTFS side. You set out to protect the records and end up stopping the records themselves.

That is why the ACL in this section is not AD alone but “allow read and write, but do not grant DE (delete), WDAC (change permissions) or WO (take ownership).” The owner can overwrite and truncate their own transcript. Accept that this part cannot be closed with an ACL.

When you want to prevent overwriting too, separate the collection point

If you want to close overwriting as well, the only way is to write to a place the writer cannot touch. There are three options.

  • With JEA, use TranscriptDirectory in the session configuration file (Chapter 7). What writes this output is not the connecting user but Local System, and Microsoft’s documentation requires that “standard users should have no access to the folder” and that access be limited to the security administrators who audit the transcripts.14 Users need no permission there at all, so the problem in this section disappears with it
  • Ingest into Windows Event Forwarding or a SIEM. Script block logs (4104) go to the event log, so they can be a forwarding target
  • Stand up your own collection process that opens append-only. Once you write the part that requests FILE_APPEND_DATA on its own, the AD in the ACE finally means something

Note that what an ACL protects you from is only “the users who write to that share.” A file server administrator can take ownership, and if the client machine is fully compromised, things can be tampered with before anything is written to the share at all. Understand the permission design in this section as guaranteeing that “users of the same share cannot erase one another’s records.”

Before rollout, try writing, reading and deleting with real user privileges

The meanings of the icacls permission symbols (WD, AD, DE, DC and so on) and the inheritance specifiers ((OI), (CI), (IO)) are listed in the Windows commands reference.9 Also, be sure to try this configuration once on a test machine before rolling it out. A transcript is appended to the same file by a running session, so if you narrow the permissions too far, the recording itself drops out. The reliable approach is to actually confirm three things: “a user’s machine can write,” “a user cannot read or modify someone else’s existing file,” and “a user cannot delete their own file.”

4. Close the Bypass Paths — PowerShell 2.0 and AMSI

4.1 AMSI hands the content to inspection just before execution

With AMSI (Antimalware Scan Interface), on PowerShell 5.0 and later the content of a script is handed to the antivirus product immediately before execution and inspected with the obfuscation undone.1 If you use a supported product, including Microsoft Defender, it works with no extra configuration.

4.2 Check whether the old engine is still present, and disable it

The problem is that the old engine does not have this mechanism. While the Windows PowerShell 2.0 engine remains enabled, powershell.exe -Version 2 leaves room to switch to an environment where neither logging nor AMSI applies. This feature is deprecated and disabling it is recommended.3

In the following code, the first half checks the state and the second half disables it. Read the result of the check before making the change; do not run all the lines unconditionally. This procedure is for environments where the 2.0 optional feature is still present; it is not a procedure for adding the feature back where it is not found.

# Check the state of the PowerShell 2.0 engine and disable it (requires administrator privileges; a restart may be needed)
Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2* |
    Select-Object FeatureName, State

Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2Root -NoRestart

4.3 Deploying 7 does not let you skip checking the 5.1 side

Note that if you have deployed PowerShell 7 (pwsh), both the setting keys and the log destinations differ from 5.1 (the policy is under PowerShellCore, the log is PowerShellCore/Operational). In an environment where both are used, apply the settings, the log sizes and the investigation queries to both. For running the versions side by side, see “The Differences Between Windows PowerShell 5.1 and PowerShell 7.”

5. Do Not Misplace the Role of the Execution Policy

This chapter separates stopping malicious action from verifying the integrity of what you distribute.

To state it again clearly: the execution policy is not a security boundary. The official documentation states explicitly that it is a safety feature to keep users from running a script unintentionally, not something that prevents malicious action.4

That said, it is not worthless. Signing has a separate value of its own: “you can confirm that the module you distributed has not been tampered with” (see “Distributing and Updating PowerShell Modules In-House”). What matters is using it with a correct understanding of its role, and the most dangerous understanding is “we set AllSigned, so we are safe.”

6. Language Modes — Use Them Together with Application Control

6.1 Compare purposes, not just how strict the restriction is

PowerShell sessions have a language mode, which restricts the language elements you can use.5

Mode What you can use Where it fits in practice
FullLanguage All language elements (the default) Normal sessions
ConstrainedLanguage All cmdlets work, and loops, conditionals, string expansion and property access are available as well. However, the .NET types you can use are limited to an allow list, and Add-Type can load only signed assemblies The mode PowerShell switches to automatically under WDAC/AppLocker. Interactive work and ordinary administration can largely be done as before
RestrictedLanguage Commands can run, but script blocks cannot be used. Variables are limited to $PSCulture, $PSUICulture, $true, $false and $null, comparison operators to -eq, -gt and -lt, and assignment, property access and method calls are not allowed The mode used for loading module manifests (.psd1). It is not meant for interactive human use
NoLanguage The scripting language itself is disabled. Neither scripts nor variables can be used; only cmdlet and native command invocations The default for a JEA session configuration (RestrictedRemoteServer). A mode for building a front end where you “just run the commands laid down for you”

The three restricted modes are not degrees on a scale; they serve different purposes. ConstrainedLanguage is for “taking an environment where people write and run scripts, and stopping only the use of dangerous types,” while NoLanguage is for “not letting anyone write a script in the first place,” and the latter only works in a limited front end like JEA.5

6.2 Check the current mode

You can check the current mode with the following.

$ExecutionContext.SessionState.LanguageMode

6.3 Look at the mechanism that enforces the restriction, not just the mode it displays

What matters is how it is configured. Constrained language mode works by PowerShell switching to it automatically when you have configured allow-list application control with WDAC (Windows Defender Application Control) or AppLocker.5 Setting it by hand through environment variables and the like is easy to bypass and does not function as a security feature. Understand that narrowing only the language mode without deploying application control gives you little return for the effort.

7. JEA — Delegating “Only the Operations That Are Needed”

What decides the real damage is, in most cases, the breadth of privileges. In a state where “the help desk holds administrator privileges” or “everyone in operations is in Domain Admins,” the compromise of one machine becomes the compromise of the whole company.

JEA (Just Enough Administration) is a mechanism that delegates specific operations without handing over administrator privileges.6 It fits requirements such as “let the help desk restart the application service, and nothing else” exactly.

This chapter follows this flow: check prerequisites → define the permitted operations (.psrc) → define the users and how it runs (.pssc) → register on the server → the user connects. .psrc and .pssc are files with different roles.

This chapter alone has different prerequisites — JEA sits on top of remoting

While Chapters 3 through 6 are self-contained local settings, JEA is built on the very mechanism of PowerShell remoting (WinRM).15 “Delegating with JEA” means registering a dedicated connection endpoint (a session configuration) on the target server and having users connect to it. Whether you can reproduce this in your own environment is decided here first.

You need these three things.

Prerequisite Details
PowerShell version JEA is available on PowerShell 5.0 and later15
Remoting enabled PowerShell remoting must be enabled on the target server. It is enabled by default on Windows Server 2012 and later; if it is disabled, run Enable-PSRemoting from an elevated PowerShell session15
Where things go The role capability file (.psrc) goes in the RoleCapabilities folder of a module on the target server, and the session configuration (.pssc) is registered on the target server. These are not things you put on the user’s machine6

In other words, steps 1 through 3 below are work you do on the server being delegated from, with administrator privileges. All the user’s machine needs is to be able to connect to that server. Configuring remoting itself and securing it is covered in “An Introduction to PowerShell Remoting (WinRM).”

Read the examples below, given their deployment paths and the use of Get-EventLog, as an example of configuring the Windows PowerShell 5.1 side of the target server. Get-EventLog is not shipped as a standard command in PowerShell 7, so this is not an example you can apply unchanged to a 7-side endpoint.16 Covering both 5.1 and 7 across the article is one thing; whether each individual sample works as is on both is another.

Step 1: Define the permitted operations in a role capability file (.psrc)

First, create the module that will hold the role file

A role capability file must be located in the RoleCapabilities folder of a PowerShell module. Simply creating a folder does not make it recognized as a module, and it cannot be resolved by name from the RoleDefinitions described later. So first create the container module, a folder with a manifest.6

# Create the module that will hold the role capabilities (a manifest is required)
$moduleRoot = 'C:\Program Files\WindowsPowerShell\Modules\KsJea'
$null = New-Item -Path "$moduleRoot\RoleCapabilities" -ItemType Directory -Force
# A module folder needs at least one file with the same name as the folder
$null = New-Item -Path "$moduleRoot\KsJea.psm1" -ItemType File -Force
New-ModuleManifest -Path "$moduleRoot\KsJea.psd1" -RootModule 'KsJea.psm1'

# Create a skeleton role capability file (the file name becomes the role name).
# Role names are resolved by "name only" across every module on PSModulePath,
# so a generic name like 'HelpDesk' can collide with a .psrc in another module.
# On a collision there is no guarantee which one is chosen, and unintended privileges get granted.
# Use a unique name with your organization's prefix
New-PSRoleCapabilityFile -Path "$moduleRoot\RoleCapabilities\KsHelpDesk.psrc"

# Confirm that it is visible as a module
Get-Module -Name KsJea -ListAvailable

Next, write the operations you expose into the role file you created

What follows is an excerpt of the content to write into the KsHelpDesk.psrc you created; it is not a configuration command to paste into a console and run. GUID = '....' is an abbreviation. Keep the actual GUID the skeleton generated, and edit the exposed commands and function definitions.

The part worth reading is that Restart-Service is not exposed directly; only Restart-KsAppService, which takes the permitted service name as an argument, is exposed.

# KsHelpDesk.psrc (excerpt) — declares "what is permitted, and how far"
@{
    GUID           = '....'
    # Read-only commands can be exposed as they are
    VisibleCmdlets = @(
        'Get-Service',
        'Get-EventLog'
    )
    # Restart-Service, which changes state, is deliberately left out of VisibleCmdlets (see below)
    # VisibleFunctions only filters "functions already loaded into the session";
    # it does not define a function. Write the body of a custom function in FunctionDefinitions,
    # and then also list its name in VisibleFunctions (you need both)
    VisibleFunctions = @('Get-KsAppStatus', 'Restart-KsAppService')
    FunctionDefinitions = @(
        @{
            Name        = 'Get-KsAppStatus'
            ScriptBlock = {
                # A function body runs in the default language mode, so it is not subject to JEA's restrictions.
                # Never pass user input straight to a dangerous command
                Get-Service -Name 'KsAppService' |
                    Microsoft.PowerShell.Utility\Select-Object Name, Status, StartType
            }
        },
        @{
            # Expose the restart as a function that can only receive its target as an argument
            Name        = 'Restart-KsAppService'
            ScriptBlock = {
                param(
                    [Parameter(Mandatory)]
                    [ValidateSet('KsAppService', 'Spooler')]
                    [string] $Name
                )
                Microsoft.PowerShell.Management\Restart-Service -Name $Name
            }
        }
    )
    VisibleExternalCommands = @()
}

Why Restart-Service is not exposed directly

The two examples below illustrate the problem with a configuration that relies on parameter restrictions alone; they are not steps you run during deployment.

ValidateSet in VisibleCmdlets alone cannot safely narrow a command that changes state. Restrictions through Parameters and ValidateSet are evaluated only when that parameter is actually bound. Because Restart-Service can receive a ServiceController from the pipeline, if someone writes

Get-Service WinRM | Restart-Service

then -Name is never bound and ValidateSet is passed straight through. On an endpoint you believed could “restart only KsAppService and Spooler,” an arbitrary service can be restarted.

The same thing happens with a different parameter set. Even if you attach a ValidateSet for LogName on Get-WinEvent, if someone writes

Get-WinEvent -ProviderName Microsoft-Windows-Security-Auditing -MaxEvents 1

then LogName is not bound and the restriction does not apply. A JEA session runs as a virtual administrator, so in this case even the Security log becomes readable.

That is why the example above keeps Restart-Service out of VisibleCmdlets and exposes only a wrapper function that can receive its target as an argument (Restart-KsAppService). If the only input path is an argument, there is no way around it.

Restrictions through Parameters and ValidateSet work only “when that parameter is bound.” For a command where binding can be avoided through pipeline input or a different parameter set, the restriction itself is void. Take it as a rule that a command whose arguments you want to narrow should be wrapped in a wrapper function.

Defining and exposing a function are two separate requirements

There are two behaviors here that are easy to trip over. The first is that VisibleFunctions does not create a function. A function whose name is merely listed does not exist in the session, and users see “no such command.” Define a custom function in FunctionDefinitions and also list it in VisibleFunctions.17 As the number grows, it becomes easier to manage if you split them out into a script module and expose that module’s functions through VisibleFunctions.

JEA does not make the inside of a function safe for you

The second is that a function body is not subject to JEA’s restrictions.17 If you want to use a constrained command that JEA substitutes, such as Select-Object, with its original behavior, call it fully qualified as Microsoft.PowerShell.Utility\Select-Object, as above. Flipped around, that means anything is possible inside a function, so absolutely avoid writing code that passes user input to Invoke-Expression.

Step 2: Define who gets which role in a session configuration file (.pssc)

Where .psrc is “what is permitted,” here you define who receives that role, which account it runs as, and where it is recorded. Substitute your target user group for EXAMPLE\HelpDesk.

TranscriptDirectory is not the same as the share users write to directly in Chapter 3. Prepare a destination where Local System has read and write permissions, standard users have no access, and only the auditing administrators can read.14

# SessionType: restricted remote server (NoLanguage by default)
# RunAsVirtualAccount: run as a virtual administrator account
# TranscriptDirectory: record what is executed
$pssc = @{
    Path                = '.\KsHelpDesk.pssc'
    SessionType         = 'RestrictedRemoteServer'
    RunAsVirtualAccount = $true
    TranscriptDirectory = 'C:\JeaTranscripts'
    RoleDefinitions     = @{ 'EXAMPLE\HelpDesk' = @{ RoleCapabilities = 'KsHelpDesk' } }
}
New-PSSessionConfigurationFile @pssc

Step 3: Register it on the server

Once the files above are ready, register the session configuration on the same target server. Creating the files alone does not make an endpoint users can connect to.

Register-PSSessionConfiguration -Name 'KsHelpDesk' -Path .\KsHelpDesk.pssc -Force

On the user side: connect to the dedicated endpoint you registered

From here on, this is what the user does. Specify the server name and the configuration name to pick the JEA endpoint you created rather than the default one.

Enter-PSSession -ComputerName 'appsrv01' -ConfigurationName 'KsHelpDesk'
# Only permitted commands are available. Restarts go through the exposed Restart-KsAppService, for the specified services only

There are three key points to JEA.6

  • The user does not hold administrator privileges. Execution happens on the virtual account side
  • The session is configured as a restricted remote server. The language mode is restricted by default, so arbitrary code cannot be executed
  • What is executed is recorded as a transcript. Who did what can be audited

Where to look when connection or role resolution goes wrong

As noted, a role capability file must be under a module’s RoleCapabilities folder, and that module must be findable through $env:PSModulePath. When the role name specified in RoleDefinitions cannot be resolved, first check with Get-Module -ListAvailable whether the module is visible.6

You can list the endpoints you registered with Get-PSSessionConfiguration. As stated at the beginning, JEA assumes a remoting configuration, so when you cannot connect, triage from the WinRM side rather than from the JEA definitions (see “An Introduction to PowerShell Remoting (WinRM)”).

8. A Practical Checklist

Entering a setting and having it behave as expected are two different things. For logging, confirm 4104 in a new session and the capacity; for the share, confirm reading, writing and deleting with user privileges; for JEA, confirm the dedicated endpoint and the exposed operations. Check each item off after confirming it in the corresponding section.

The “write-only share” in the table refers to the permission design in Chapter 3. Include in your operational judgment the fact that it is not a configuration that prevents overwriting your own records.

Item Priority Status
Script block logging (4104) enabled High Distributed company-wide by GPO218
Sizes of the PowerShell-related logs expanded High At the defaults they are gone within days
Transcription enabled and aggregated to a write-only share High Do not keep it on the local machine2
PowerShell 2.0 engine disabled High A bypass path around logging and AMSI3
The antivirus product supports AMSI High Enabled by default1
The execution policy is not mistaken for a security boundary Medium Signing has a separate value4
The scope of granted administrator privileges has been inventoried High What decides the blast radius is the breadth of privileges
Routine tasks delegated through JEA Medium Directly reduces administrator privileges6
WDAC/AppLocker considered Medium Language mode restriction goes together with this5
Logging settings distributed to the PowerShell 7 side too Medium The settings differ between 5.1 and 7

9. Summary

Here are the judgments above, gathered as the points to revisit at deployment time.

  • Banning PowerShell has little effect and stops the business. The policy is visibility and restriction.
  • The top priority is enabling script block logging (4104). Obfuscated code is recorded in its expanded form too. Do it together with expanding the log size.
  • Aggregate transcription to a share users cannot delete from. To prevent overwriting your own records as well, however, use a collection point users cannot touch, such as JEA’s TranscriptDirectory or event forwarding.
  • Disable the PowerShell 2.0 engine, so that no path where neither logging nor AMSI applies is left behind.
  • The execution policy is not a security boundary. Signing has value as tamper detection, but do not make it the core of your defense.
  • The breadth of privileges decides the breadth of the damage. Delegate “only the operations that are needed” with JEA and you can keep operations running without handing out administrator privileges.

Downloading the Sample Code

The code covered in this article is distributed together in a form you can run directly. It includes enabling the logging settings and creating the JEA endpoint.

Download the sample code (zip)

Because the samples in this article depend on Windows and on your tenant, they have not been verified by execution. Syntax parsing and static analysis with PSScriptAnalyzer have been run against every file, but be sure to confirm the behavior on your own test machine.

# Syntax parsing + static analysis (runs on non-Windows too)
./Invoke-SampleTests.ps1

The settings (paths, server names, tenant IDs and so on) are examples. Do not run them against production as they are; substitute values that match your own environment.

KomuraSoft LLC handles security configuration reviews for Windows operational environments, operational design including privilege delegation (JEA), and consulting on establishing and making use of audit logs.

References

  1. Microsoft Learn, Antimalware Scan Interface (AMSI). On AMSI being a mechanism by which applications and services pass content to any antimalware product for inspection, and on Windows scripting engines including PowerShell being integrated with it so that even obfuscated scripts can be inspected with their content as of execution time.  2 3 4

  2. Microsoft Learn, about_Logging_Windows. On the three logging features (module logging, script block logging and transcription), enabling them through Group Policy and the registry (under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell), script block logging recording de-obfuscated code as event ID 4104, module logging being recorded as event ID 4103, and the OutputDirectory and EnableInvocationHeader settings for transcription.  2 3 4 5 6 7

  3. Microsoft Learn, Deprecating Windows PowerShell 2.0. On the Windows PowerShell 2.0 engine being deprecated and disabling it being recommended, and on it being provided as a Windows optional feature that can be enabled or disabled.  2 3

  4. Microsoft Learn, about_Execution_Policies. On the execution policy not being a security boundary but a safety feature to keep users from running scripts unintentionally, and on several ways around it existing.  2 3

  5. Microsoft Learn, about_Language_Modes. On the language elements available in each of the FullLanguage, ConstrainedLanguage, RestrictedLanguage and NoLanguage modes, checking the current mode with $ExecutionContext.SessionState.LanguageMode, PowerShell running in constrained language mode in environments where application control through WDAC or AppLocker is enabled, and setting the language mode manually not being intended as a security feature.  2 3 4 5

  6. Microsoft Learn, Just Enough Administration (JEA) Overview. On JEA being a mechanism that delegates only specific administrative tasks without granting administrator privileges, VisibleCmdlets in a role capability file (.psrc) and restriction through Parameters and ValidateSet, RestrictedRemoteServer, RunAsVirtualAccount, TranscriptDirectory and RoleDefinitions in a session configuration file (.pssc), registration with Register-PSSessionConfiguration, and the language mode being restricted in a JEA session. For the requirement to place role capability files in the RoleCapabilities folder of a PowerShell module (keeping it discoverable as a module, and creating files with New-PSRoleCapabilityFile) see JEA Role Capabilities 2 3 4 5 6 7

  7. Microsoft Learn, about_Group_Policy_Settings. On the Group Policy for PowerShell 7 being located at “Administrative Templates > PowerShell Core”, the need to install the administrative templates, and the option of using the Windows PowerShell policy values.  2

  8. Microsoft Learn, about_Logging_Windows (PowerShell 7). On the PowerShell 7 event log being PowerShellCore/Operational, and on Windows requiring the event provider to be registered before writing, using $PSHOME\RegisterManifest.ps1 with administrator privileges. 

  9. Microsoft Learn, icacls. On it being the command that displays and modifies access control lists on files and folders, granting permissions with /grant and removing granted permissions with /remove:g, the detailed permission symbols that can be specified in a permission mask (DE=delete, DC=delete subfolders and files, WD=write data/create files, AD=append data/create subfolders, WA=write attributes, WEA=write extended attributes, RA=read attributes, X=execute/traverse, F=full access and so on), and the inheritance specifiers ((OI)=object inherit, (CI)=container inherit). For creating a share and specifying its access permissions see New-SmbShare (-FullAccess / -ChangeAccess / -ReadAccess), and for disabling inheritance see ObjectSecurity.SetAccessRuleProtection (the first argument protects against inheritance and the second specifies whether inherited ACEs are kept). All this method can control is the handling of inherited ACEs; ACEs granted directly on the object are out of its scope. To drop direct grants, remove them individually with something like ObjectSecurity.RemoveAccessRuleSpecific 2 3

  10. Microsoft Learn, Special Identity Groups. On OWNER RIGHTS (S-1-3-4) being a group that represents the current owner of an object, and on the fact that when an ACE holding this SID is applied to an object, the system ignores the implicit READ_CONTROL and WRITE_DAC for the owner. Conversely, without this ACE the owner can implicitly rewrite the DACL.  2

  11. Microsoft Learn, File Access Rights Constants. On FILE_APPEND_DATA being defined as “For a file object, the right to append data to the file. (For local files, write operations will not overwrite existing data if this flag is specified without FILE_WRITE_DATA.)”, on the fact that append-only writing holds when the file is opened “specifying FILE_APPEND_DATA without FILE_WRITE_DATA, and on the meanings of FILE_WRITE_DATA / FILE_ADD_FILE and FILE_DELETE_CHILD. For the generic access rights mappings (FILE_GENERIC_WRITE = FILE_APPEND_DATA + FILE_WRITE_ATTRIBUTES + FILE_WRITE_DATA + FILE_WRITE_EA + STANDARD_RIGHTS_WRITE + SYNCHRONIZE, FILE_GENERIC_READ = FILE_READ_ATTRIBUTES + FILE_READ_DATA + FILE_READ_EA + STANDARD_RIGHTS_READ + SYNCHRONIZE) see File Security and Access Rights 2

  12. PowerShell, TranscriptionOption.FlushContentToDisk (src/System.Management.Automation/engine/hostifaces/MshHostUserInterface.cs). On the transcript write handle being opened with new FileStream(this.Path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read) and reopened with new FileStream(this.Path, FileMode.Append, FileAccess.Write, FileShare.Read) only if that fails, and on there being no path that requests FILE_APPEND_DATA on its own. 

  13. .NET, SafeFileHandle.Open (src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Windows.cs). On FileAccess.Read / FileAccess.Write being mapped to GENERIC_READ / GENERIC_WRITE respectively, and on FileMode.Append merely being replaced with FileMode.OpenOrCreate before CreateFile is called, so that FILE_APPEND_DATA alone is never used in the access mask. 

  14. Microsoft Learn, JEA Session Configurations. On transcripts being recorded automatically when you specify a folder in TranscriptDirectory in the session configuration file, and on “Transcripts are written to the folder by the Local System account, which requires read and write access to the directory. Standard users should have no access to the folder. Limit the number of security administrators that have access to audit the transcripts.” (standard users should not have access to this folder, and access should be limited to the administrators who audit it).  2

  15. Microsoft Learn, JEA Prerequisites. On JEA being available in PowerShell 5.0 and later, PowerShell remoting being the foundation of JEA and needing to be enabled and properly secured before you use JEA, and PowerShell remoting being enabled by default on Windows Server 2012 and later and enabled by running Enable-PSRemoting from an elevated PowerShell window when it is not.  2 3

  16. Microsoft Learn, Differences between Windows PowerShell 5.1 and PowerShell 7.x. On the *-EventLog cmdlets including Get-EventLog having been removed from the standard commands in PowerShell 7, and on using Get-WinEvent to retrieve events on Windows. 

  17. Microsoft Learn, JEA Role Capabilities. On the need to define custom functions in FunctionDefinitions and also list their names in VisibleFunctions (“Don’t forget to add the name of your custom functions to the VisibleFunctions field so they can be run by the JEA users.”), on a function body (script block) running in the system default language mode and not being subject to JEA’s restrictions, on needing a fully qualified name (Microsoft.PowerShell.Utility\Select-Object) to use the original implementation of a constrained command that JEA substitutes, on the approach of splitting functions out into a script module and exposing them through VisibleFunctions when there are many, and on a module folder needing a file with the same name as the folder.  2

  18. Microsoft Learn, Set-ItemProperty. On -Type being added as a dynamic parameter when the registry provider is used, letting you specify the data type of a registry value (String / ExpandString / Binary / DWord / MultiString / QWord and so on). Includes the fact that the value is created if it does not exist. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Should we ban PowerShell inside the company as a security measure?
We do not recommend it: the effect is small and the side effects are large. PowerShell is the management foundation of Windows itself, and what it really is is a set of capabilities on top of .NET. Blocking the executable still leaves the same APIs callable by other means, so it is unlikely to be much of an obstacle to an attacker, while it will reliably stop legitimate administration and automation. The realistic policy is visibility and restriction, not prohibition. Record what was executed with script block logging, disable old versions, and where necessary narrow what can be run with language modes or JEA.
If we enable script block logging, won't we be swamped with logs?
The volume really does increase, so enable it together with your log size settings. If you leave the maximum size of the Microsoft-Windows-PowerShell/Operational log at its default, it will wrap within a short period and the entries you need will be gone when it matters. Operationally, allocate a generous log size and, if necessary, aggregate through a SIEM or event forwarding. There is also a setting that records the more detailed start and stop of invocations, but its output volume is very high, so normally you enable only the standard script block logging.
Does setting the execution policy to AllSigned count as a security measure?
The execution policy is not a security boundary. The official documentation states explicitly that it is a mechanism to keep users from unintentionally running dangerous scripts, not something that stops malicious action, because several ways around it exist. Signing has a separate value of its own, namely being able to verify the integrity of what you distribute, but keep the core of your defense on visibility through logs, inspection through AMSI, and restriction of privileges through language modes and JEA.
Is it acceptable to set constrained language mode (ConstrainedLanguage) by hand?
Setting it manually through environment variables and the like is not recommended, because it is easy to bypass and does not function as a security feature. Constrained language mode is designed to be used as the result of configuring application control with WDAC (Windows Defender Application Control) or AppLocker, at which point PowerShell switches to it automatically. Narrowing only the language mode without deploying application control does not give you an effective defense.
I want to let a help desk operator restart one specific service on a server, and nothing more.
JEA (Just Enough Administration) is the mechanism for exactly that. In a role capability file you define "allow only this value, of this parameter, of this command," and you register it as a session configuration. Users can run only the permitted operations, through a virtual account, without holding administrator privileges. The session is configured as a restricted remote server and the language mode is restricted by default, so there is no room to execute arbitrary code. What is run can be recorded as a transcript.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog