This guide assumes Windows Powershell 5.1 or Powershell 7 on Windows, with WinRM enabled on the target host (
Enable-PSRemoting -Force). JEA is a Windows feature; it doesn't apply to Powershell on Linux.
You have a junior admin who needs to restart one specific service on production servers. The options are bad: hand them domain admin (no), give them local admin everywhere (slightly less no), or write a wrapper script that prompts for credentials they shouldn't have anyway. JEA is the fourth option.
A JEA endpoint is a constrained Powershell session that lets a non-privileged user invoke a specific allow-listed set of cmdlets and parameters under a virtual privileged account without ever seeing or knowing the privileged credential.
The Two Files
JEA is configured by two files:
- Role Capability file (
.psrc) what the role can do. Cmdlets, parameters, modules, scripts. - Session Configuration file (
.pssc) who gets which role and which identity they run as.
Both live under module folders that JEA scans at registration time.
Building a Role Capability
The role: helpdesk can restart the Spooler service and read its status. Nothing else.
$module = "$env:ProgramFiles\WindowsPowerShell\Modules\HelpdeskOps"
$null = New-Item -ItemType Directory -Path "$module\1.0.0\RoleCapabilities" -Force
# Manifest so the module is importable
$manifest = @{
Path = "$module\1.0.0\HelpdeskOps.psd1"
RootModule = 'HelpdeskOps.psm1'
ModuleVersion = '1.0.0'
}
New-ModuleManifest @manifest
'' | Set-Content "$module\1.0.0\HelpdeskOps.psm1"
$roleCap = @{
Path = "$module\1.0.0\RoleCapabilities\PrintAdmin.psrc"
VisibleCmdlets = @(
@{ Name = 'Restart-Service'; Parameters = @{ Name = 'Name'; ValidateSet = @('Spooler') } },
@{ Name = 'Get-Service'; Parameters = @{ Name = 'Name'; ValidateSet = @('Spooler') } }
)
VisibleAliases = @()
VisibleFunctions = @('Get-Help', 'TabExpansion2')
VisibleProviders = @()
}
New-PSRoleCapabilityFile @roleCap
Three rules to remember:
VisibleCmdletsis an allow-list. Anything not listed is invisible to the user.Parameters.ValidateSetis enforced. The user can callRestart-Service -Name Spoolerbut not-Name Anything-Else. You can also useValidatePatternfor regex-style constraints (mutually exclusive withValidateSet).VisibleProviders @()removes filesystem, registry, etc. That's intentional. JEA endpoints should be small.
Include
TabExpansion2inVisibleFunctionsif you want tab completion to work inside the endpoint. Without it, users hit Tab and get nothing a frequent source of "is this thing broken?" tickets.
Building a Session Configuration
The session config wires the role to AD groups and to a virtual identity:
$sessionConfig = @{
Path = 'C:\JEA\HelpdeskPrint.pssc'
SessionType = 'RestrictedRemoteServer'
RunAsVirtualAccount = $true
TranscriptDirectory = 'C:\JEA\Transcripts'
RoleDefinitions = @{
'CORP\Helpdesk-Print' = @{ RoleCapabilities = 'PrintAdmin' }
}
}
New-PSSessionConfigurationFile @sessionConfig
Key options:
-SessionType RestrictedRemoteServerthe locked-down session type. Runs in NoLanguage mode. Exposes only a tiny default set:Get-Command,Get-Help,Get-FormatData,Select-Object,Measure-Object,Out-Default,Exit-PSSession,Clear-Host.-RunAsVirtualAccountJEA spins up an ephemeral local admin (or domain admin if on a DC) for the session. The user never sees the credential. To scope the virtual account to specific local groups instead of full admin, add-RunAsVirtualAccountGroups 'NetworkOperator','NetworkAuditor'.-TranscriptDirectoryevery JEA session writes a full transcript. This is your audit trail. The directory must be writable by Local System (the account that actually writes the transcripts).-RoleDefinitionsmap AD groups (or users) to role capabilities. One user can have many roles;RoleCapabilitiestakes an array.
Virtual accounts vs. Group-Managed Service Accounts
Virtual accounts are perfect when the role only manages the local machine. If the user needs to hit network resources from inside the session (file shares, SQL Server over the wire, a web service), you want a group-managed service account (GMSA) instead:
$webOpsConfig = @{
Path = 'C:\JEA\WebOps.pssc'
SessionType = 'RestrictedRemoteServer'
GroupManagedServiceAccount = 'CORP\JEA_WebOps_GMSA$'
TranscriptDirectory = 'C:\JEA\Transcripts'
RoleDefinitions = @{
'CORP\WebOps-Team' = @{ RoleCapabilities = 'WebAdmin' }
}
}
New-PSSessionConfigurationFile @webOpsConfig
The GMSA needs to exist in AD first (New-ADServiceAccount) and the host must be authorized to retrieve its password. GMSAs are harder to trace to an individual user (all sessions share the identity) lean on transcripts and script-block logs for attribution.
Conditional access: layering MFA / smart card / JIT
-RequiredGroups lets you demand additional security-group membership that doesn't affect which roles the user gets ideal for requiring MFA, smart-card sign-in, or a JIT elevation group on top of role membership:
$hardenedConfig = @{
Path = 'C:\JEA\HelpdeskPrint.pssc'
SessionType = 'RestrictedRemoteServer'
RunAsVirtualAccount = $true
TranscriptDirectory = 'C:\JEA\Transcripts'
RoleDefinitions = @{
'CORP\Helpdesk-Print' = @{ RoleCapabilities = 'PrintAdmin' }
}
RequiredGroups = @{ Or = '2FA-logon', 'smartcard-logon' }
}
New-PSSessionConfigurationFile @hardenedConfig
Nested hashtables with And / Or keys compose as expected: @{ And = 'elevated-jea', @{ Or = '2FA-logon', 'smartcard-logon' } }.
Validating the .pssc before registering
Test-PSSessionConfigurationFile -Path 'C:\JEA\HelpdeskPrint.pssc'
# True = safe to register. False = fix syntax before touching WinRM.
Register-PSSessionConfiguration will reject a malformed .pssc with a cryptic error always run Test-PSSessionConfigurationFile first.
Register the Endpoint
$registerParams = @{
Name = 'HelpdeskPrint'
Path = 'C:\JEA\HelpdeskPrint.pssc'
Force = $true
}
Register-PSSessionConfiguration @registerParams
This registers an endpoint named HelpdeskPrint on the local host. WinRM picks it up immediately.
Connecting As a User
From a member of CORP\Helpdesk-Print:
Enter-PSSession -ComputerName srv01 -ConfigurationName HelpdeskPrint
The connecting prompt now looks like a normal Powershell session except:
[srv01]: PS> Get-Command
# only the allow-listed commands appear
[srv01]: PS> Get-Service -Name Spooler
# works
[srv01]: PS> Get-Service -Name BITS
# error: parameter validation failed
[srv01]: PS> Stop-Computer
# command not found
The user is running as a local admin (the virtual account) but can only do the four things you allowed.
What Else Goes in a Role Capability
The full set of allow-list keys:
$fullRoleCap = @{
Path = '...'
VisibleCmdlets = @( ... )
VisibleFunctions = @( 'Restart-AppPool' )
VisibleAliases = @( 'gsv' )
VisibleExternalCommands = @( 'C:\Tools\diag.exe' )
VisibleProviders = @( 'FileSystem' )
ScriptsToProcess = @( 'C:\JEA\bootstrap.ps1' )
ModulesToImport = @( 'WebAdministration' )
EnvironmentVariables = @{ MY_ENV = 'prod' }
}
New-PSRoleCapabilityFile @fullRoleCap
VisibleFunctionsis where you put your team's wrapper functions. A customRestart-AppPoolfunction that knows the right pool names is much safer than exposing rawRestart-WebAppPoolwith wildcard validation.
Custom Functions in a JEA Endpoint
The cleanest pattern: put your wrapper functions in the same module as the role capability, and expose only the wrappers:
# HelpdeskOps.psm1
function Restart-PrintSpooler
{
<#
.SYNOPSIS
Restarts the Print Spooler service.
#>
[CmdletBinding()]
param()
Restart-Service -Name Spooler
Get-Service -Name Spooler
}
Export-ModuleMember -Function Restart-PrintSpooler
# PrintAdmin.psrc
$printAdmin = @{
Path = '...\PrintAdmin.psrc'
VisibleFunctions = @('Restart-PrintSpooler')
}
New-PSRoleCapabilityFile @printAdmin
Now the helpdesk user just calls Restart-PrintSpooler clean, named, audited, with no exposure to the underlying cmdlets.
Auditing
Every JEA session writes a transcript to the directory you specified:
C:\JEA\Transcripts\
└── 20260202\
└── PowerShell_transcript.SRV01.helpdesk01.20260202090214.txt
Each transcript includes:
- The connecting user's identity
- The virtual account it ran as
- Every command issued
- All output
Ship these to the same SIEM you ship script-block logs to (covered in the logging post).
Audit the audit. A user with permissions on
C:\JEA\Transcriptscould delete their own session log. Lock the directory down toSYSTEMonly and forward off-host immediately.
Operational Patterns
Discoverability for users:
# What endpoints are available on srv01?
Get-PSSessionConfiguration -ComputerName srv01
# What can I do in this endpoint?
Enter-PSSession srv01 -ConfigurationName HelpdeskPrint
[srv01]: PS> Get-Command
The latter is the best discoverability story Powershell has Get-Command only lists what's actually allowed.
Multiple roles on one endpoint:
-RoleDefinitions @{
'CORP\Helpdesk-Print' = @{ RoleCapabilities = 'PrintAdmin' }
'CORP\Helpdesk-Web' = @{ RoleCapabilities = 'WebAdmin' }
'CORP\Senior-Ops' = @{ RoleCapabilities = 'PrintAdmin','WebAdmin','SchedTaskAdmin' }
}
A user gets the union of every matching role. Senior ops automatically inherit the helpdesk roles.
Updating a role capability is safe: edit the .psrc, and any new session started after the save reflects the revised capabilities. No re-register needed.
Updating a session configuration (.pssc) is not. To change anything in the session config user→role mapping, identity, transcript path, conditional-access rules you must unregister and re-register:
Unregister-PSSessionConfiguration -Name 'HelpdeskPrint' -Force
Register-PSSessionConfiguration -Name 'HelpdeskPrint' -Path 'C:\JEA\HelpdeskPrint.pssc'
A naked Register-PSSessionConfiguration -Force will reject a re-registration of an existing name. Do the unregister step first.
Versioning role capabilities is still handled via module versioning: bump 1.0.0 to 1.0.1, drop in a new RoleCapabilities folder under the new version. The module's previous version stays on disk in case you need to roll back the PSModulePath preference.
Letting users copy files in/out MountUserDrive
Sometimes a role needs to receive a file from the user (a config patch, a CSV of hostnames to operate on). Adding MountUserDrive = $true to the session config creates a per-user User: PSDrive that persists across sessions the user can Copy-Item into it without you exposing the filesystem provider:
$userDriveConfig = @{
Path = 'C:\JEA\HelpdeskPrint.pssc'
SessionType = 'RestrictedRemoteServer'
RunAsVirtualAccount = $true
TranscriptDirectory = 'C:\JEA\Transcripts'
MountUserDrive = $true
UserDriveMaximumSize = 52428800
RoleDefinitions = @{
'CORP\Helpdesk-Print' = @{ RoleCapabilities = 'PrintAdmin' }
}
}
New-PSSessionConfigurationFile @userDriveConfig
Default quota is 50 MB; override with -UserDriveMaximumSize (bytes). The drive is persistent schedule a nightly cleanup task if you don't want that.
What JEA Doesn't Do
- It doesn't constrain what the wrapper script does. If your
VisibleFunctionslist contains a function that internally runsStop-Computer, the user can stop the computer. JEA constrains the interface, not the implementation. - It doesn't replace MFA, RBAC, or PIM. Layer it underneath those JEA is "what they can do once they're on the box," not "should they be on the box at all."
- It doesn't work over SSH. WinRM only. For mixed Win/Linux fleets, JEA covers Windows; the Linux side needs
sudorules. - Language mode is constrained. No script blocks created at runtime, no
Invoke-Expression, limited type access. Test your wrapper functions inside the endpoint, not from a normal session.
A Real-World Setup
Production JEA usually looks like this:
- One module per team (
HelpdeskOps,WebOps,DBOps). - Each module exports a small set of wrapper functions.
- Each role capability exposes only the wrappers, no raw cmdlets.
- One session config per host class (
WebServer.pssc,DBServer.pssc) registered the same way on every member of the class via DSC or a baseline script. - Transcripts forwarded to the SIEM in real time.
- AD group membership is the only knob adding/removing a user from
CORP\Helpdesk-Printinstantly grants/revokes access on every host the endpoint is registered on.
What to Do Next
JEA is one of those Windows features that solves a real problem and almost nobody uses. Two files, one cmdlet to register, full audit trail, no shared credentials, no domain admin handout.
Three concrete moves to pilot JEA in your environment this quarter:
- Pick one painful "give-them-admin" request you've seen recently (helpdesk needing to restart Print Spooler, Tier-2 needing to clear a print queue, a team needing to read IIS logs). Build the role capability for exactly that command set. Register on five test hosts. Watch the request volume drop.
- Wire the transcript directory to a centralized share that JEA users cannot write outside of. The audit log is the whole point; do not put it on a writable share where users can edit their own history.
- Pair JEA with
RequiredGroupsso the elevated role only fires for users in your MFA-enforcement group. Now "compromised admin password" stops working in JEA contexts even if password discipline elsewhere fails.
Pairs naturally with the logging post (every JEA session generates events that belong in your SIEM) and the secret-management post (the GMSA-vs-virtual-account decision is parallel to the certificate-vs-secret one for service authentication).


