juanfont/headscale · error
ssh action: cached binding does not match request src/dst
Error message
ssh action: cached binding does not match request src/dst
What it means
Returned by noiseServer.sshActionFollowUp (hscontrol/noise.go:630-641) when an SSH action follow-up's (src, dst) node pair does not match the binding cached for its auth_id. This is a security check: without it, anyone who learned an auth_id could submit a verdict for a different node pair. The HTTP response is 401 'src/dst pair does not match auth session'.
Source
Thrown at hscontrol/noise.go:55
// ErrSSHDstNodeNotFound is returned when the dst node id on a Noise SSH
// action request does not match any registered node.
var ErrSSHDstNodeNotFound = errors.New("ssh action: unknown dst node id")
// ErrSSHMachineKeyMismatch is returned when the Noise session's machine
// key does not match the dst node referenced in the SSH action URL.
var ErrSSHMachineKeyMismatch = errors.New(
"ssh action: noise session machine key does not match dst node",
)
// ErrSSHAuthSessionNotBound is returned when an SSH action follow-up
// references an auth session that is not bound to an SSH check pair.
var ErrSSHAuthSessionNotBound = errors.New(
"ssh action: cached auth session is not an SSH-check binding",
)
// ErrSSHBindingMismatch is returned when an SSH action follow-up's
// (src, dst) pair does not match the cached binding for its auth_id.
var ErrSSHBindingMismatch = errors.New(
"ssh action: cached binding does not match request src/dst",
)
const (
// ts2021UpgradePath is the path that the server listens on for the WebSockets upgrade.
ts2021UpgradePath = "/ts2021"
// The first 9 bytes from the server to client over Noise are either an HTTP/2
// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an "early payload"
// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes
// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].
// The early payload is optional. Some servers may not send it... But we do!
earlyPayloadMagic = "\xff\xff\xffTS"
// noiseBodyLimit is the maximum allowed request body size for Noise protocol
// handlers. This prevents unauthenticated OOM attacks via unbounded [io.ReadAll].
// No legitimate Noise request ([tailcfg.MapRequest], [tailcfg.RegisterRequest], etc.) comes close
// to this limit; typical payloads are a few KB.View on GitHub (pinned to 565fd254d0)
Solutions
- Compare the pairs in the error message (cached X->Y vs request A->B) and use the cached pair's URL exactly
- Re-initiate the SSH connection so a fresh check binding is minted for the current node pair
- Audit for tampering if the request did not originate from your own client — this error indicates a genuine authorization mismatch
Defensive patterns
Strategy: try-catch
Try / catch
if errors.Is(err, ErrSSHBindingMismatch) { // 401: security mismatch; do not retry, re-initiate the SSH session so a fresh binding is created } Prevention
- Treat this error as a possible probing signal and alert on it
- Clients should always derive (src,dst) from the current SSH session, not from cached URLs
- Never share or persist SSH action follow-up URLs
When it happens
Trigger: A follow-up request whose URL srcNodeID/dstNodeID differ from auth.SSHCheckBinding() cached at check-creation time. The error message includes both pairs ('cached A->B, request C->D') so mismatches are easy to confirm.
Common situations: Tampered or proxied follow-up URLs (attack/probing); client bug mixing up src and dst ordering after a reconnect; NAT rebinding causing the client to resume an SSH session with swapped roles.
Related errors
- ssh action: cached auth session is not an SSH-check binding
- oidc state parameter is too short
- empty OIDC callback params
- extracting ID token
- registration info not in cache
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/0980a109ca712730.
Report an issue: GitHub.