{"record":{"id":"48da3034e9f69b1e","repo":"juanfont/headscale","slug":"ssh-action-cached-auth-session-is-not-an-ssh-chec","errorCode":null,"errorMessage":"ssh action: cached auth session is not an SSH-check binding","messagePattern":"ssh action: cached auth session is not an SSH-check binding","errorType":"http","errorClass":null,"httpStatus":null,"severity":"error","filePath":"hscontrol/noise.go","lineNumber":49,"sourceCode":"// ErrMissingURLParameter is returned when a required URL parameter is not provided.\nvar ErrMissingURLParameter = errors.New(\"missing URL parameter\")\n\n// ErrNoAuthSession is returned when an auth_id does not match any active auth session.\nvar ErrNoAuthSession = errors.New(\"no auth session found\")\n\n// ErrSSHDstNodeNotFound is returned when the dst node id on a Noise SSH\n// action request does not match any registered node.\nvar ErrSSHDstNodeNotFound = errors.New(\"ssh action: unknown dst node id\")\n\n// ErrSSHMachineKeyMismatch is returned when the Noise session's machine\n// key does not match the dst node referenced in the SSH action URL.\nvar ErrSSHMachineKeyMismatch = errors.New(\n\t\"ssh action: noise session machine key does not match dst node\",\n)\n\n// ErrSSHAuthSessionNotBound is returned when an SSH action follow-up\n// references an auth session that is not bound to an SSH check pair.\nvar ErrSSHAuthSessionNotBound = errors.New(\n\t\"ssh action: cached auth session is not an SSH-check binding\",\n)\n\n// ErrSSHBindingMismatch is returned when an SSH action follow-up's\n// (src, dst) pair does not match the cached binding for its auth_id.\nvar ErrSSHBindingMismatch = errors.New(\n\t\"ssh action: cached binding does not match request src/dst\",\n)\n\nconst (\n\t// ts2021UpgradePath is the path that the server listens on for the WebSockets upgrade.\n\tts2021UpgradePath = \"/ts2021\"\n\n\t// The first 9 bytes from the server to client over Noise are either an HTTP/2\n\t// settings frame (a normal HTTP/2 setup) or, as Tailscale added later, an \"early payload\"\n\t// header that's also 9 bytes long: 5 bytes ([earlyPayloadMagic]) followed by 4 bytes\n\t// of length. Then that many bytes of JSON-encoded [tailcfg.EarlyNoise].\n\t// The early payload is optional. Some servers may not send it... But we do!","sourceCodeStart":31,"sourceCodeEnd":67,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/noise.go#L31-L67","documentation":"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'.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before: reusing a registration auth_id for an SSH follow-up\nurl := fmt.Sprintf(\"/ssh/action/%d/accept\", registrationAuthID)\n\n// after: use the auth_id from the SSH action notification itself\nurl := sshActionNotification.ActionURL (contains the bound auth_id)","handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"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 }","preventionTips":["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"],"tags":["ssh","noise","auth","headscale"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}