How to Configure PowerShell Just Enough Administration (JEA) on Windows Server 2025

Just Enough Administration (JEA) is a PowerShell security technology that lets you delegate specific administrative tasks to users or groups without granting them full administrator rights. Instead of handing someone a domain admin account so they can restart a service, JEA creates a constrained endpoint where they can execute only the exact commands you permit — and nothing else. On Windows Server 2025, JEA is built into WinRM and requires no additional installation, making it the right tool for any delegation scenario where least privilege matters. This guide provides a complete walkthrough of building, testing, and auditing a production-grade JEA endpoint.

Prerequisites

  • Windows Server 2025 with PowerShell 5.1 or later
  • WinRM service running (Start-Service WinRM)
  • Administrator access to create session configurations
  • Active Directory groups for role-based access (recommended)
  • Pester module for endpoint testing: Install-Module Pester -Force
  • Group Managed Service Account (gMSA) configured for production deployments

Step 1: Plan Your Roles and Capabilities

Before writing any files, map out which AD groups need which capabilities. This design phase prevents the most common JEA mistake: granting too broad access because roles were not clearly defined upfront.

For this guide we will build two roles:

  • WebOperators — can restart specific web services, read IIS logs, view event logs
  • NetworkOperators — can run network diagnostics, flush DNS, view network adapter configuration

Step 2: Create Role Capability Files (.psrc)

Role Capability Files define what a role can do. They live in a RoleCapabilities subfolder of a PowerShell module directory.

# Create the module structure
$modulePath = 'C:JEAModulesServerOpsJEA'
New-Item -Path "$modulePathRoleCapabilities" -ItemType Directory -Force

# Create the WebOperators role capability
New-PSRoleCapabilityFile -Path "$modulePathRoleCapabilitiesWebOperators.psrc"

Edit the generated WebOperators.psrc file with your role definition. The key sections are VisibleCmdlets for filtered cmdlet access, VisibleFunctions for custom functions, and VisibleExternalCommands for binaries.

# WebOperators.psrc (populated)

@{
    GUID = (New-Guid).Guid  # replace with a fixed GUID in production

    # Restrict which cmdlets are visible and with what parameter constraints
    VisibleCmdlets = @(
        'Get-Service',
        'Get-Process',
        @{
            Name       = 'Restart-Service'
            Parameters = @{
                Name = @('nginx', 'W3SVC', 'WAS', 'AppHostSvc', 'WMSVC')
            }
        },
        @{
            Name       = 'Stop-Service'
            Parameters = @{
                Name = @('nginx', 'W3SVC', 'WAS')
            }
        },
        @{
            Name       = 'Start-Service'
            Parameters = @{
                Name = @('nginx', 'W3SVC', 'WAS', 'AppHostSvc')
            }
        },
        'Get-EventLog',
        'Get-WinEvent',
        'Get-Content',
        'Select-Object',
        'Where-Object',
        'Sort-Object',
        'Format-Table',
        'Format-List',
        'Measure-Object',
        'Out-Default',
        'Exit-PSSession'
    )

    # Custom functions defined within this psrc (no module needed)
    FunctionDefinitions = @(
        @{
            Name        = 'Get-IISAccessLog'
            ScriptBlock = {
                param([ValidateRange(1,7)][int]$LastDays = 1)
                $cutoff = (Get-Date).AddDays(-$LastDays)
                Get-ChildItem 'C:inetpublogsLogFiles' -Recurse -Filter '*.log' |
                    Where-Object LastWriteTime -gt $cutoff |
                    ForEach-Object { Get-Content $_.FullName -Tail 200 }
            }
        }
    )

    # Expose selected external commands with exact path to prevent PATH manipulation
    VisibleExternalCommands = @(
        'C:WindowsSystem32netstat.exe',
        'C:WindowsSystem32ping.exe',
        'C:WindowsSystem32ipconfig.exe'
    )

    # Allow basic tab-completion providers
    VisibleProviders = @('FileSystem', 'Function', 'Variable')
}

Create a separate NetworkOperators.psrc with network-specific cmdlets:

# NetworkOperators.psrc
@{
    GUID = (New-Guid).Guid

    VisibleCmdlets = @(
        'Get-NetAdapter',
        'Get-NetIPAddress',
        'Get-NetIPConfiguration',
        'Get-NetRoute',
        'Test-NetConnection',
        'Test-Connection',
        'Resolve-DnsName',
        @{
            Name       = 'Clear-DnsClientCache'
            Parameters = @{}   # no parameter restriction — cmdlet has no risky params
        },
        'Get-DnsClientCache',
        'Get-NetFirewallRule',
        'Get-NetTCPConnection',
        'Select-Object','Where-Object','Sort-Object','Format-Table','Out-Default','Exit-PSSession'
    )

    VisibleExternalCommands = @(
        'C:WindowsSystem32ping.exe',
        'C:WindowsSystem32tracert.exe',
        'C:WindowsSystem32nslookup.exe',
        'C:WindowsSystem32netstat.exe'
    )
}

Step 3: Create a Module Manifest for the Role Capabilities

New-ModuleManifest -Path "$modulePathServerOpsJEA.psd1" `
    -RootModule ''  `
    -ModuleVersion '1.0.0' `
    -Description 'JEA Role Capabilities for Server Operations'

The module must be in a path listed in $env:PSModulePath for JEA to discover the role capability files:

# Add module path system-wide (persist across reboots)
$currentPath = [System.Environment]::GetEnvironmentVariable('PSModulePath','Machine')
if ($currentPath -notmatch 'C:\JEA\Modules') {
    [System.Environment]::SetEnvironmentVariable(
        'PSModulePath',
        "$currentPath;C:JEAModules",
        'Machine'
    )
    $env:PSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath','Machine')
}

Step 4: Configure a gMSA RunAs Account

Virtual accounts are the simplest option (auto-created per-session, local admin on the server), but Group Managed Service Accounts provide a traceable, auditable identity that works across multiple servers.

# On the domain controller — create the gMSA
Add-KdsRootKey -EffectiveImmediately   # only once per domain

New-ADServiceAccount -Name 'JEA-WebOps' `
    -DNSHostName 'JEA-WebOps.corp.example.com' `
    -PrincipalsAllowedToRetrieveManagedPassword 'WEB01$','WEB02$'

# On the target server — install and verify the gMSA
Install-ADServiceAccount -Identity 'JEA-WebOps'
Test-ADServiceAccount -Identity 'JEA-WebOps'

Step 5: Create the Session Configuration File (.pssc)

The session configuration file ties roles to AD groups and configures the endpoint itself. A single endpoint can serve multiple roles.

$psscPath = 'C:JEAConfigsServerOps.pssc'
New-PSSessionConfigurationFile -Path $psscPath `
    -SessionType RestrictedRemoteServer `
    -RunAsVirtualAccount   # use -GroupManagedServiceAccount 'CORPJEA-WebOps$' for gMSA

# Edit the file to add RoleDefinitions mapping AD groups to role capability names

The critical RoleDefinitions section in the .pssc:

# ServerOps.pssc (relevant section)
RoleDefinitions = @{
    'CORPWebOperators'     = @{ RoleCapabilities = 'WebOperators' }
    'CORPNetworkOperators' = @{ RoleCapabilities = 'NetworkOperators' }
    'CORPSeniorOps'        = @{ RoleCapabilities = 'WebOperators', 'NetworkOperators' }
}

# Transcript every session to a central share for audit
TranscriptDirectory = '\audit-shareJEATranscripts'

# Language mode — NoLanguage is most restrictive (recommended)
LanguageMode = 'NoLanguage'

Step 6: Register and Test the JEA Endpoint

# Register the endpoint (requires Admin; replaces existing if -Force)
Register-PSSessionConfiguration -Path 'C:JEAConfigsServerOps.pssc' `
    -Name 'ServerOps' `
    -Force

# Verify registration
Get-PSSessionConfiguration -Name 'ServerOps' | Format-List

# Test as a member of WebOperators
$session = New-PSSession -ComputerName 'WEB01' -ConfigurationName 'ServerOps' `
           -Credential (Get-Credential 'CORPwebop-user1')

# These should work
Invoke-Command -Session $session { Restart-Service -Name 'W3SVC' }
Invoke-Command -Session $session { Get-Service }

# These should be blocked
Invoke-Command -Session $session { Get-ADUser -Filter * }     # should fail
Invoke-Command -Session $session { Restart-Service 'MSSQLSERVER' }  # blocked by parameter filter

Remove-PSSession $session

Step 7: Pester Tests for JEA Endpoints

# JEA.Tests.ps1
Describe 'JEA WebOperators Endpoint' {
    BeforeAll {
        $cred = Import-Clixml 'C:ScriptsTestCredswebop-user1.xml'
        $script:session = New-PSSession -ComputerName 'WEB01' `
            -ConfigurationName 'ServerOps' -Credential $cred
    }

    AfterAll {
        if ($script:session) { Remove-PSSession $script:session }
    }

    It 'Allows Get-Service' {
        { Invoke-Command -Session $script:session { Get-Service } } | Should -Not -Throw
    }

    It 'Allows Restart-Service for permitted service names' {
        { Invoke-Command -Session $script:session { Restart-Service -Name 'W3SVC' -WhatIf } } |
            Should -Not -Throw
    }

    It 'Blocks Restart-Service for non-permitted service names' {
        { Invoke-Command -Session $script:session { Restart-Service -Name 'MSSQLSERVER' } } |
            Should -Throw
    }

    It 'Blocks access to AD cmdlets' {
        { Invoke-Command -Session $script:session { Get-ADUser -Filter * } } | Should -Throw
    }
}

Invoke-Pester -Path '.JEA.Tests.ps1' -Output Detailed

Conclusion

JEA on Windows Server 2025 gives administrators a precise, auditable delegation model that eliminates the need for shared administrator accounts and over-privileged service accounts. Role Capability Files define what each role can do at the parameter level, so even permitted cmdlets cannot be abused. Session Configuration Files map AD groups to roles and enforce transcript logging to a central audit share. Virtual accounts or gMSAs ensure that actions run under a known identity rather than the caller’s credentials. Pester tests provide ongoing assurance that the endpoint behaves as designed after every change. Together these components deliver meaningful least-privilege enforcement in environments where full administrator rights would otherwise be the default.