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

  1. Compare the pairs in the error message (cached X->Y vs request A->B) and use the cached pair's URL exactly
  2. Re-initiate the SSH connection so a fresh check binding is minted for the current node pair
  3. 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

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


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/0980a109ca712730. Report an issue: GitHub.