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
- Verify the auth_id used in the follow-up is the one returned in the SSH action notification for the same check flow
- Confirm the SSH rule that triggered the action uses action "check" (accept-only rules never create bindings)
- Check server logs for the auth_id to see which flow minted it and discard stale URLs on the client
- 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
- Only follow action URLs delivered by the control server's SSH action notification
- Never reuse auth_ids across registration and SSH flows
- Log auth_id plus flow type on mint to trace mismatches
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
- ssh action: cached binding does not match request src/dst
- 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/48da3034e9f69b1e.
Report an issue: GitHub.