The first failure: Access Denied on the second hop

A deployment script copies a build artifact from a file share to a web server. Run it from the workstation and it works. Run it in an RDP session on the web server and it works. Run it through Invoke-Command and it dies:

Invoke-Command -ComputerName WEB01 -ScriptBlock {
    Get-ChildItem \\FILE01\deploy
}
Get-ChildItem: Access to the path '\\FILE01\deploy' is denied.
    + CategoryInfo          : PermissionDenied: (\\FILE01\deploy:String) [Get-ChildItem], UnauthorizedAccessException
    + FullyQualifiedErrorId : ItemExistsUnauthorizedAccessError,Microsoft.PowerShell.Commands.GetChildItemCommand

Nothing about the share changed. The account has rights on it. The only difference is the route the authentication takes: workstation to WEB01 is the first hop, WEB01 to FILE01 is the second. The first hop succeeds. The second one has no credential to present, so FILE01 says no.

Why the second hop fails

When Invoke-Command connects with the default Kerberos authentication, the Key Distribution Center authenticates you to WEB01 but does not hand WEB01 anything it can reuse. The remote session runs under a network logon token: good enough to act as you on WEB01 itself, useless for proving who you are to a third machine. NTLM behaves the same way. Your credential stops at the first hop. That single sentence is the entire root cause, and every fix below is just a different answer to the question of how FILE01 learns who the caller is.

Four ways past the second hop

Approach What it does Security tradeoff Setup effort When to pick it
CredSSP Forwards your credential, including password-hash material, to WEB01, which then authenticates to FILE01 as you High: a compromised WEB01 yields a reusable credential. Never use domain admin credentials here Low: two commands plus one Group Policy setting Lab work, or a tightly controlled admin host where you accept the risk. Fastest path to a working script
Kerberos constrained delegation A domain admin permits the WEB01 computer account to obtain tickets for specific services only, such as cifs/FILE01 Low: nothing secret leaves your workstation, and the scope is limited to named services Medium: Delegation tab in ADUC or Set-ADComputer; requires a domain admin Permanent production paths between fixed machines
Resource-based constrained delegation FILE01’s own computer account lists which machines are allowed to delegate to it Low, and no domain admin required — the resource owner controls the trust Medium: one Set-ADComputer against the target; needs a 2012+ domain functional level You administer the file server but not the domain
Avoid the hop Restructure so there is no second hop: copy from your workstation, target FILE01 directly, or use a JEA endpoint whose RunAs account already has share rights None — no credential forwarding involved Low: a design decision, not a feature to enable Any workflow you own; usually the correct answer

CredSSP

CredSSP is the quick fix and the dangerous one, in that order. Enable it on both ends:

# On your admin workstation: allow delegating fresh credentials to WEB01
Enable-WSManCredSSP -Role Client -DelegateComputer WEB01.contoso.com

# On WEB01: allow receiving delegated credentials
Enable-WSManCredSSP -Role Server

One Group Policy detail decides whether the client side actually works: Computer Configuration → Administrative Templates → System → Credentials Delegation → “Allow delegating fresh credentials”. Add wsman/WEB01.contoso.com to the server list (wildcards such as wsman/*.contoso.com are accepted). Enable-WSManCredSSP -Role Client writes the local policy, but a domain GPO overrides it silently — if the setting is managed centrally, get the entry added there instead of wondering why the local change does nothing.

CredSSP demands explicit credentials at call time:

$cred = Get-Credential CONTOSO\deploy-svc
Invoke-Command -ComputerName WEB01 -Authentication CredSSP -Credential $cred -ScriptBlock {
    Get-ChildItem \\FILE01\deploy
}

The warning matters more than the syntax: CredSSP hands your credential to the remote host, and a compromised WEB01 turns into a compromised account. Use a dedicated service account with only the rights the task needs, never a domain admin, and turn CredSSP off when the work is done:

Disable-WSManCredSSP -Role Client
Disable-WSManCredSSP -Role Server

Kerberos constrained delegation

The classic answer. A domain admin scopes the WEB01 computer account so the KDC will issue it tickets for FILE01’s file service and nothing else:

# Run as a domain admin
Set-ADComputer -Identity WEB01 -Add @{
    'msDS-AllowedToDelegateTo' = 'cifs/FILE01.contoso.com'
}

The GUI equivalent is the Delegation tab on the WEB01 computer object in Active Directory Users and Computers: “Trust this computer for delegation to specified services only”. Two things break this silently: the WEB01 account flagged “Account is sensitive and cannot be delegated”, and a missing or misspelled service principal name on FILE01. Check both before assuming the feature itself is broken.

Resource-based constrained delegation

Same Kerberos machinery, but the trust is declared on the receiving end. Whoever administers FILE01 decides which front-end machines may delegate to it — no domain admin involved:

# Run by the FILE01 administrator
Set-ADComputer -Identity FILE01 `
    -PrincipalsAllowedToDelegateToAccount @(Get-ADComputer WEB01)

This needs a domain functional level of Windows Server 2012 or newer. It fits the common real-world split where the team owning the file server is not the team owning the domain.

Avoiding the hop entirely

Often the cheapest fix is refusing to play. Your workstation already holds tickets for both machines, so do the copy there:

Copy-Item -Path \\FILE01\deploy\webapp.zip `
    -Destination \\WEB01\c$\inetpub\wwwroot\app.zip

Or put a Just Enough Administration endpoint on WEB01 whose RunAs account has rights on the share. The remote session then authenticates to FILE01 as the RunAs account with its own Kerberos ticket. No delegation, no forwarded credential — no second-hop problem because there is no second hop.

The second failure: your variable is empty over there

Different script, different confusion:

$svc = 'wuauserv'

Invoke-Command -ComputerName WEB01 -ScriptBlock {
    Restart-Service -Name $svc -Force
}
Restart-Service: Cannot bind argument to parameter 'Name' because it is null.
    + CategoryInfo          : InvalidData: (:) [Restart-Service], ParameterBindingValidationException
    + FullyQualifiedErrorId : ParameterArgumentValidationErrorNullNotAllowed,Microsoft.PowerShell.Commands.RestartServiceCommand

The remote session never saw $svc. The variable lives in your session; the script block runs in WEB01’s. Sometimes it fails loudly like above. Sometimes it fails silently — "Restarting $svc" prints Restarting and the script marches on with an empty value, which is worse.

The fix is the $using: scope modifier:

Invoke-Command -ComputerName WEB01 -ScriptBlock {
    Restart-Service -Name $using:svc -Force
}

That is the whole fix, and it is also the beginning of the misunderstandings.

What the using scope actually does

$using: tells PowerShell to capture the variable’s value from your session and pack it into the remoting payload. The remote side receives a copy. Three properties of that copy trip people up.

First, it is a read-only snapshot taken when the command is invoked. Assigning to it is rejected at parse time, before anything runs — verified on PowerShell 7.6:

ParserError:
Line |
   1 |  Invoke-Command -ComputerName WEB01 -ScriptBlock { $using:svc = 'bits' }
     |                                                   ~~~~~~~~~~
     | The assignment expression is not valid. The input to an assignment operator
     | must be an object that is able to accept assignments, such as a variable
     | or a property.

Second, later changes to the local variable do not propagate. The value is frozen at invocation — verified with a background job:

$mode = 'stopped'
$job = Start-Job -ScriptBlock {
    Start-Sleep -Milliseconds 800
    "sees: $using:mode"
}
$mode = 'running'   # too late — the snapshot was already taken
Receive-Job $job -Wait
# sees: stopped

Third, $using: works anywhere PowerShell moves a script block into a new runspace — including ForEach-Object -Parallel in PowerShell 7, where each thread gets its own snapshot:

$svc = 'wuauserv'
'WEB01','WEB02' | ForEach-Object -Parallel {
    Get-Service -Name $using:svc -ComputerName $_
} -ThrottleLimit 4

Nesting the two needs one extra step. Inside -Parallel, an inner Invoke-Command script block’s $using: resolves against the parallel runspace, not your session — so pull the value into the runspace first, then into the remote payload:

$svc = 'wuauserv'

'WEB01','WEB02' | ForEach-Object -Parallel {
    $name = $using:svc   # step 1: session -> parallel runspace
    Invoke-Command -ComputerName $_ -ScriptBlock {
        Restart-Service -Name $using:name -Force   # step 2: runspace -> remote host
    }
} -ThrottleLimit 4

When $using: feels awkward — many values, or targets old enough to predate it — -ArgumentList with a param() block does the same job and works everywhere, including PowerShell 2.0-era hosts:

Invoke-Command -ComputerName WEB01 -ArgumentList $svc -ScriptBlock {
    param($name)
    Restart-Service -Name $name -Force
}

Where the using scope stops working

A few boundaries worth knowing before they bite.

Running the block locally defeats it. Invoke-Command -ScriptBlock { $using:x } with no -ComputerName or -Session never crosses a session boundary, so there is nothing to serialize into — tested, and the error is explicit:

FullyQualifiedErrorId: UsingWithoutInvokeCommand,Microsoft.PowerShell.Commands.InvokeCommandCommand
A Using variable cannot be retrieved. A Using variable can be used only with
Invoke-Command, Start-Job, or InlineScript in the script workflow. When it is
used with Invoke-Command, the Using variable is valid only if the script block
is invoked on a remote computer.

Workflows are the subtle one. $using: exists in PowerShell Workflow’s InlineScript, but there it imports workflow variables — not the variables from the session that launched the workflow. The mental model of “reach back to my locals” does not transfer; pass values as workflow parameters instead.

And the snapshot goes through the remoting serializer. Live objects do not survive the trip — runspaces, file handles, and open remote sessions come back as hollow property bags or fail outright. Pass names, paths, and IDs; rehydrate on the far side.

Decision checklist

  • The remote command touches a third machine?
    • One-off or lab: CredSSP with a dedicated low-privilege account, then Disable-WSManCredSSP when done.
    • Permanent production path: Kerberos constrained delegation, scoped to the exact service.
    • You own the target, not the domain: resource-based constrained delegation.
    • You own the workflow: skip delegation entirely — stage from your workstation or use a JEA RunAs account.
  • A remote block needs your local values?
    • One or two simple values: $using:.
    • Many values, or ancient targets: -ArgumentList with param().
    • -Parallel wrapping Invoke-Command: copy $using: to a local first, then $using: again inside.
  • Remember the snapshot rules: read-only, frozen at invocation, serialized. If the remote side must report back, return the value and assign it locally.

Related reading: try/catch Is Blind to Half Your Errors for the other half of “my remote script failed silently”, and 50 PowerShell Tips for Windows Admins for the $using: one-liner this post expands on.