PowerShell Security & Logging
PowerShell ships on every modern Windows host and gets used constantly for entirely legitimate work: patch scripts, inventory pulls, Active Directory administration, scheduled maintenance, and countless one-off admin tasks that never touch a ticketing system. That ubiquity means a SOC analyst's job is not to treat PowerShell activity as inherently suspicious, because flagging every invocation produces an unworkable volume of noise. The job is to know exactly what visibility exists into PowerShell execution, what a normal baseline looks like in your environment, and which logs to pull the moment something needs a closer look. This chapter covers the logging mechanisms Windows provides for PowerShell, the specific Event IDs each one produces, and the configuration controls that turn PowerShell from a visibility gap into one of the richest telemetry sources on the endpoint.
PowerShell and Why Visibility Matters
Deep Integration, Minimal Footprint
PowerShell is not a standalone scripting language bolted onto Windows. It is built directly on the .NET Common Language Runtime and has first-class access to WMI, COM, and the Windows API surface. A single PowerShell command can query Active Directory, touch the registry, manipulate services, and reach out over the network, all without invoking a separate executable for each step.
That depth of integration is exactly why PowerShell activity is fundamentally different from a suspicious binary dropped to disk. A malicious PowerShell script does not need to write a new file at all. It can be handed to the engine as a string, assembled dynamically at runtime, decoded from an encoded argument, or pulled down and executed entirely in memory.
Why Antivirus Alone Falls Short
That in-memory, script-based execution model is precisely why traditional file-based antivirus scanning is a weak control against PowerShell misuse on its own. A signature-based scanner looks for known-bad files on disk; if nothing is ever written to disk, there is nothing for it to find. This is not a statement about attacker cleverness, it is a statement about where the actual control has to live: in PowerShell's own logging and execution-policy layer, not in the file system.
Microsoft built PowerShell with this reality in mind. The logging features covered in this chapter exist specifically to give defenders visibility into commands and scripts regardless of whether they ever touch disk.
Treat It Like Core Telemetry
PowerShell logging configuration deserves the same deliberate rollout as any other core telemetry source, alongside process creation auditing (Event ID 4688) and EDR agent coverage. An organization with strong process-level logging but no PowerShell-specific logging only sees that powershell.exe launched, not what it actually ran.
The reverse gap matters too. An organization with rich PowerShell logging but no baseline for normal administrative usage will drown analysts in alert volume with no way to prioritize.
A Defense-in-Depth Stack
The remainder of this chapter treats PowerShell logging as a defense-in-depth stack: multiple independent mechanisms, each with a different blind spot, that together give a SOC meaningfully complete visibility. None of these controls is sufficient alone.
- Capture mechanisms. Module Logging, Script Block Logging, and Transcription each record a different slice of an execution.
- Restriction mechanisms. Constrained Language Mode, JEA, AppLocker, and WDAC restrict what can run in the first place.
Understanding what each one does, and does not, capture is the foundation for building reliable PowerShell detections.
Module Logging
What It Captures
Module Logging records pipeline execution details for the specific PowerShell modules an administrator has enabled it for. When active, it writes the commands that ran through a given module, plus their parameters, as Event ID 4103 to the Microsoft-Windows-PowerShell/Operational log.
In practical terms, it tells an analyst that a particular cmdlet from a particular module executed, and what arguments were passed to it. That is genuinely useful when investigating an incident where a known module was involved, for example confirming that a particular Active Directory cmdlet ran against a specific object.
The Opt-In Limitation
The practical limitation of Module Logging is right in its name: it only logs modules that have been explicitly enabled for logging. Windows PowerShell ships with dozens of built-in modules, and organizations frequently install more, so an administrator must select which ones generate 4103 events, commonly through the "Turn on Module Logging" Group Policy setting with a wildcard or an explicit module list.
A module that was never added to that list produces no 4103 telemetry no matter what runs through it. Enabling logging for every module (using a wildcard such as *) is the common recommendation for security-relevant environments, but that decision has a real cost in log volume that needs to be sized for before rollout.
The Content-Completeness Gap
Module Logging captures pipeline execution details, command names, and parameters, but it was never designed to be a full-fidelity record of everything a script does internally. Code that runs without invoking a logged module's cmdlets, or logic that operates purely on .NET objects and language constructs rather than module commands, can execute without generating a corresponding 4103 event.
This is why Module Logging is described as complementary rather than a complete solution: it is strong at telling you which commands ran, weaker at reconstructing a script's full logic.
When to Pull It
Because of that gap, Module Logging is rarely the first log source an analyst pulls during an investigation. It is more often used to corroborate a finding from Script Block Logging, confirming that a specific command with specific parameters actually executed against a specific module, or to answer narrow questions like "did anyone run Get-ADUser against this account in the last week."
Treat it as a supporting log source that adds command-level detail once you already have a lead, not as your primary detection surface.
Script Block Logging
What Gets Logged
Script Block Logging is the single most valuable PowerShell log source available to a defender. When enabled through the "Turn on PowerShell Script Block Logging" policy, it writes Event ID 4104 to the Microsoft-Windows-PowerShell/Operational log every time a block of PowerShell code is executed, whether that code was typed interactively, run from a saved script file, or built dynamically at runtime.
What makes this the highest-value source is that it logs the code as it is actually compiled and executed by the PowerShell engine, not merely the command line that launched the process. If a script assembles a command dynamically, for example by concatenating strings or decoding a Base64-encoded block before running it, Script Block Logging captures the resulting executed content, not just the original wrapper that process-creation logging alone would show. That distinction, as-executed versus as-typed, is what separates a log source that can be trivially starved of content from one that reliably captures the substance of what ran.
The Built-In Heuristic Safety Net
PowerShell also has a built-in heuristic layer independent of the "Turn on Script Block Logging" policy: certain patterns considered suspicious by the engine's own detection logic trigger a warning-level 4104 event automatically, even in environments that have not explicitly enabled full Script Block Logging.
This is a useful safety net, but it should never be relied on as a substitute for deliberately enabling the policy, because it only fires on a defined set of patterns rather than logging comprehensively.
Multi-Part Script Blocks
Very long script blocks are split across multiple 4104 events that share a common identifier so they can be reassembled. Each event's fields, including MessageNumber and MessageTotal, tell you how to stitch a multi-part script back together.
When building detections or doing manual triage, remember that a single suspicious script may show up as several linked 4104 records rather than one, and your query logic or SIEM parser needs to account for that.
The Volume Tradeoff
Volume is the honest tradeoff here. Because Script Block Logging captures essentially all executed PowerShell code across the environment, including routine administrative scripts, scheduled tasks, and management tooling that itself runs on PowerShell under the hood, 4104 can be one of the higher-volume event IDs a SOC ingests.
That is a reason to plan storage and SIEM licensing accordingly, not a reason to avoid enabling it. The alternative, no visibility into executed code content at all, is a materially worse position for an investigator to be in.
| Log Source | Captures | Enabled By Default |
|---|---|---|
| Module Logging | Pipeline execution details for enabled modules | No |
| Script Block Logging | Actual code as compiled and executed, including dynamically assembled content | No (heuristic warnings only) |
PowerShell Transcription
What It Captures That the Others Don't
PowerShell Transcription writes a plain-text record of an entire interactive session, both the commands entered and the output that was returned, to a file on disk. The field that distinguishes it from the other two logging mechanisms is output: Script Block Logging and Module Logging both tell you what code ran, but neither tells you what that code returned or printed to the console.
Transcription fills exactly that gap. If a script queries a system and prints results, enumerates data and displays it, or an operator runs a diagnostic command and reads the output on screen, that output is captured in the transcript file. For an investigation trying to reconstruct not just what an operator or a script did but what they saw and knew as a result, transcript output can be the deciding piece of evidence.
Configuration and Storage
Transcription is enabled through the "Turn on PowerShell Transcription" Group Policy setting, which corresponds to the registry key under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription with the EnableTranscripting value set. Administrators can also start a transcript manually within a session using the Start-Transcript cmdlet, though for enterprise visibility the policy-driven, always-on configuration is what matters.
Transcript files are written to disk as text, by default to each user's Documents folder unless an OutputDirectory is configured, with a filename that includes the computer name and a timestamp. In a managed environment, that output directory is almost always redirected to a centralized, write-restricted network share so an operator cannot simply delete their own local transcript to cover their tracks, and so a SIEM or log shipper can pick the files up for centralized retention.
Limitations
Transcription records what appears in the session's input and output stream, which for content assembled or executed silently in memory without any interactive echo may be thinner than what Script Block Logging captures for the same activity.
The two features are designed to be run together, and Microsoft's own guidance treats them as complementary: Script Block Logging for the as-executed code, Transcription for the human-readable narrative of a session including its results.
Constrained Language Mode and JEA
- CLM (Constrained Language Mode): a PowerShell language mode that blocks calls to arbitrary .NET/COM types, leaving only approved commands.
- JEA (Just Enough Administration): role-based, constrained PowerShell endpoints that limit what cmdlets an operator can run.
- AppLocker / WDAC: application control policies whose enforcement is what triggers CLM (covered fully later in this chapter).
Constrained Language Mode
PowerShell supports several language modes, and Constrained Language Mode is the one built specifically as a security boundary. It is designed to support ordinary day-to-day administrative tasks while restricting access to the language elements that allow scripts to call arbitrary .NET methods, instantiate arbitrary .NET or COM types, or otherwise reach outside the set of approved commands. A script running under CLM can still do a great deal of legitimate administrative work, but the sensitive building blocks that let it interact directly with low-level system APIs are cut off.
The important operational detail is how CLM actually gets applied. PowerShell does not switch into it on its own; it is triggered automatically when PowerShell is running under an enforced application control policy, specifically AppLocker or Windows Defender Application Control (WDAC), and the script or session in question falls outside what that policy explicitly allows to run in Full Language Mode. Without an application control policy enforcing it, PowerShell defaults to Full Language Mode regardless of any other setting.
That means CLM is not something you toggle in isolation, it is a downstream effect of an AppLocker or WDAC deployment, which is why it belongs in the same conversation as execution restriction rather than as a purely standalone control. An analyst can check the active mode for a given session with $ExecutionContext.SessionState.LanguageMode.
Just Enough Administration (JEA)
JEA takes a different but complementary approach: rather than restricting what language constructs are available inside a session, it restricts what an operator can do in the first place by defining role-based, constrained PowerShell endpoints. An administrator connecting through a JEA endpoint is limited to a specific, pre-approved set of cmdlets, functions, and parameters defined in that endpoint's role capability file, rather than getting an open-ended session with their full standing permissions.
JEA sessions commonly run under a temporary virtual account or a group-managed service account, so an operator can perform an action that genuinely requires elevated rights without ever holding standing admin credentials themselves.
From a logging perspective, JEA has a built-in advantage: because every JEA session runs through a defined, constrained endpoint, it is straightforward to configure transcription and detailed logging specifically for that endpoint, giving you a clean audit trail of exactly which commands a given operator ran during a privileged session. That pairing, least-privilege access plus mandatory session logging, directly supports incident response by narrowing the set of actions any single compromised credential could have taken.
| Control | What It Restricts | How It's Triggered |
|---|---|---|
| Constrained Language Mode | Language elements: arbitrary .NET/COM calls, low-level API access | Automatically, by AppLocker or WDAC enforcement |
| JEA | Which cmdlets/parameters an operator can invoke, via a role-defined endpoint | Explicitly configured endpoint an operator connects to |
Why They're Not Logging Mechanisms
Neither CLM nor JEA is a logging mechanism in itself, and it is worth being explicit about that distinction. They are hardening controls that shrink what is possible to execute or which endpoint an operator can reach, which reduces the attack surface a defender has to monitor in the first place. Deploying them alongside Script Block Logging and Transcription, rather than instead of them, is the combination that produces both fewer bad outcomes and better evidence when something does happen.
PowerShell Event IDs to Monitor
Two Logs, Two Eras
Two separate Windows Event Logs carry PowerShell telemetry, and analysts building detections need to know which one to query for which purpose. The legacy "Windows PowerShell" log carries the classic engine lifecycle and pipeline events going back to early PowerShell versions.
The newer Microsoft-Windows-PowerShell/Operational log, introduced with PowerShell v3 and the module-based logging architecture, carries the higher-fidelity events this chapter has focused on. A mature detection stack pulls from both, because some tooling and some older systems still only populate the legacy log.
The Reference Table
The table below is the practical reference for a SOC analyst wiring up detection content: which Event ID fires, in which log, and what it actually tells you.
| Event ID | Log | Meaning |
|---|---|---|
| 400 | Windows PowerShell | Engine state changed to Available; a new PowerShell session/pipeline has started |
| 403 | Windows PowerShell | Engine state changed to Stopped; the session has ended |
| 600 | Windows PowerShell | A provider (such as the registry or file system provider) was started within the session |
| 800 | Windows PowerShell | Pipeline execution details; a summary record of a command or script that ran, useful as a fallback when Operational-log detail is unavailable |
| 4103 | Microsoft-Windows-PowerShell/Operational | Module Logging: pipeline execution details for a module enabled for logging, including command and parameters |
| 4104 | Microsoft-Windows-PowerShell/Operational | Script Block Logging: the actual code compiled and executed, including dynamically assembled or decoded content |
Filtering by Level
4104 deserves special attention when building alerting logic. Most 4104 events are logged at an informational or verbose level and represent completely routine execution. A smaller subset are logged at warning level because they matched PowerShell's built-in suspicious-content heuristics.
That Level field is a cheap, high-value filter for surfacing the events worth a first look without having to inspect every single script block logged across the fleet.
Correlating Sessions
When correlating across these events, the HostID and RunspaceID fields are what tie a session together. A single investigation often needs to walk from a 400 (session start), through a series of 4104 events (the code that ran), to a 403 (session end), all sharing the same identifiers.
Building that correlation into your SIEM's parsing logic ahead of time saves significant time during an actual incident rather than reconstructing it manually under pressure.
Establishing a Baseline and Spotting Anomalies
Build the Baseline First
Effective PowerShell detection depends on knowing what normal looks like in your specific environment before trying to define what abnormal looks like. Every organization has its own mix of scheduled maintenance scripts, configuration management tooling, and admin habits, and a pattern that would be alarming in one environment is background noise in another.
Spend time reviewing a representative sample of 4104 events during a quiet period to build that baseline: what modules run routinely, what parent processes typically launch PowerShell, and roughly how long or complex a normal script block tends to be.
Four Signals Worth Correlating
Against that baseline, four patterns consistently earn a closer look. None of them is proof on its own, each is a prioritization signal.
- Script length and entropy. A script block that is unusually long, or that consists largely of high-entropy character sequences such as long runs of random-looking encoded or compressed text, is worth a look. This is not proof of anything on its own, legitimate tooling sometimes embeds compressed resources or generated data, but it is a low-cost, high-signal filter for triage. Pairing a length or entropy threshold with the warning-level flag on 4104 events is a reasonable starting point for a detection rule.
- Parent process. PowerShell launched interactively by a logged-on administrator from a normal shell looks very different, from a process-tree perspective, than PowerShell spawned as a child of an Office application, a scripting host, or another process that does not typically launch PowerShell in your environment. Correlating 4688 process-creation events (or your EDR's equivalent) with the PowerShell logs described in this chapter surfaces execution chains worth investigating even when the script block content itself looks unremarkable.
- Command-line flags. Flags that suppress the normal interactive experience, such as running without loading the usual profile, running with a hidden or minimized window, or bypassing an execution policy check, are commonly used by both legitimate automation (a scheduled task has no reason to load an interactive profile) and by activity that specifically wants to avoid drawing attention. The presence of such flags does not distinguish the two cases by itself; combining them with the parent process, the script content, and whether the calling account and time of day fit the baseline does.
- Frequency and timing. A service account that runs the same PowerShell task every night at 2 AM is baseline. The same account suddenly running an unfamiliar script block at an unusual hour, or a workstation account invoking PowerShell at all when it never has before, is a deviation worth a look regardless of what the script content contains. Building alerting around deviation from an established per-host or per-account baseline, rather than around any single static rule, produces far better signal-to-noise than one universal "this script is bad" pattern.
Signals, Not Verdicts
The consistent theme across all of these signals is that none of them, on their own, means malicious activity occurred. Encoded content, unusual parent processes, quiet-hour execution, and profile-suppressing flags are all things legitimate administration does routinely somewhere in most environments.
The analyst's job is to treat each as a prioritization signal that earns a script a closer read, not as an automatic verdict, and to build detection logic that scores and correlates these signals rather than firing hard on any single one in isolation.
Restricting Execution with AppLocker and WDAC
Two Controls, One Goal
Logging tells you what happened. AppLocker and Windows Defender Application Control (WDAC) are the complementary side of the equation: controls that restrict which scripts are allowed to run in the first place, rather than simply recording that they ran. Both let an administrator define policy governing which scripts, executables, and modules are permitted to execute on a system, evaluated against rules based on file path, publisher signature, or file hash.
AppLocker vs WDAC
AppLocker is the older and more approachable of the two, letting administrators build rules that determine whether a script is allowed to run at all, and separately, whether it runs in Full Language Mode or is dropped into Constrained Language Mode. It is a reasonable fit for organizations that need targeted script restriction without the deeper platform integration that WDAC requires.
WDAC (the modern evolution of what used to be called Device Guard) is a kernel-enforced control that locks down which scripts, modules, and PowerShell commands can run at a lower, harder-to-bypass level, and its enforcement is what automatically triggers Constrained Language Mode for anything that falls outside the allowed policy.
| Control | Enforcement Level | Best Fit |
|---|---|---|
| AppLocker | User-mode policy, rule-based (path, publisher, hash) | Targeted script restriction without deep platform integration |
| WDAC | Kernel-enforced, harder to bypass | Organizations needing lockdown-grade control |
Division of Responsibility
It is worth being precise about how these two controls divide responsibility, since they solve different problems. WDAC and AppLocker govern whether a given script is permitted to execute at all. Constrained Language Mode governs what a script is allowed to do even once it has been permitted to run, restricting it away from the sensitive .NET and COM access described earlier in this chapter.
Neither one replaces logging: a permitted script that runs under Constrained Language Mode is still worth capturing in Script Block Logging, both to confirm the restriction held and to have a record of exactly what executed.
Why Both Together
This is why execution restriction and logging are described as complementary rather than redundant. A logging-only posture gives you excellent visibility but no ability to actually stop something from running before it does damage. An execution-restriction-only posture can block a lot of unauthorized activity but, without logging, gives an investigator little to go on when trying to understand what a permitted script actually did.
Deploying both together, backed by the baseline-driven triage approach from the previous section, is what gives a SOC realistic, defensible coverage of PowerShell activity across an enterprise.
Key Takeaways
- PowerShell's deep .NET and WMI integration and its ability to run entirely in memory make logging configuration, not antivirus, the primary visibility control for this activity.
- Module Logging (Event ID 4103) records pipeline execution details but only for modules explicitly enabled for logging, and does not by itself capture the full logic of a script.
- Script Block Logging (Event ID 4104) captures the actual code as compiled and executed, including dynamically assembled or decoded content, making it the highest-value PowerShell log source.
- Transcription writes a full text record of session input and output, filling the gap that neither Module Logging nor Script Block Logging covers: what a script actually returned.
- Constrained Language Mode and JEA are hardening controls, not logging mechanisms. CLM is triggered by AppLocker or WDAC enforcement; JEA constrains operators to role-defined, auditable endpoints.
- None of the anomaly signals covered here, script length, entropy, parent process, suppressed-profile flags, or off-baseline timing, are proof of malicious activity on their own. Treat each as a reason to look closer, not a verdict.
- AppLocker and WDAC restrict what can execute at all, and their enforcement is what triggers Constrained Language Mode. Execution restriction plus logging together cover both prevention and investigation in a way neither does alone.
Knowledge Check
Click an answer to reveal the explanation.
During an investigation, you find Event ID 4103 records showing a specific cmdlet ran, but you need to see the exact code that assembled and executed the payload dynamically at runtime. Which log source should you pull next?
You are scoping a PowerShell logging rollout and need to prioritize due to SIEM ingestion cost. Which single logging feature should be enabled first if only one can be deployed initially?
A workstation account that has never run PowerShell before suddenly generates a 4104 event at 3 AM for a script block that suppresses the normal profile-loading behavior. What is the correct analyst response?