juanfont/headscale · error

ssh action: cached auth session is not an SSH-check binding

Error message

ssh action: cached auth session is not an SSH-check binding

What it means

Returned by the Noise SSH action follow-up handler (hscontrol/noise.go:621-626) when the auth_id in the follow-up URL resolves to a cached auth session that is not an SSH-check binding. Headscale binds each SSH 'check' action to a specific auth session; a follow-up posted against an auth_id minted for a normal registration or interactive login fails this check. The HTTP response is 400 'auth session is not for SSH check'.

Source

Thrown at hscontrol/noise.go:49

// ErrMissingURLParameter is returned when a required URL parameter is not provided.
var ErrMissingURLParameter = errors.New("missing URL parameter")

// ErrNoAuthSession is returned when an auth_id does not match any active auth session.
var ErrNoAuthSession = errors.New("no auth session found")

// 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!

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Verify the auth_id used in the follow-up is the one returned in the SSH action notification for the same check flow
  2. Confirm the SSH rule that triggered the action uses action "check" (accept-only rules never create bindings)
  3. Check server logs for the auth_id to see which flow minted it and discard stale URLs on the client
  4. If writing a custom client, never construct follow-up URLs manually; only follow URLs supplied by the control server

Example fix

// before: reusing a registration auth_id for an SSH follow-up
url := fmt.Sprintf("/ssh/action/%d/accept", registrationAuthID)

// after: use the auth_id from the SSH action notification itself
url := sshActionNotification.ActionURL (contains the bound auth_id)
Defensive patterns

Strategy: try-catch

Try / catch

if errors.Is(err, ErrSSHAuthSessionNotBound) { // 400: auth_id belongs to a non-SSH flow; discard the URL and let the client re-request the SSH action }

Prevention

When it happens

Trigger: A client POSTs to the SSH action follow-up URL (/ssh/action/{auth_id}/...) with an auth_id that exists in the auth cache but whose session was created for node registration or OIDC login rather than an SSH check. Concretely: auth.IsSSHCheck() returns false in noiseServer.sshActionFollowUp.

Common situations: Client-side bug that reuses an auth_id from a different flow; replaying an old/captured SSH action URL after the original SSH session completed and a new auth session took the slot; hand-crafted follow-up requests during penetration testing.

Related errors


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