{"record":{"id":"b4bddf0627aadab3","repo":"juanfont/headscale","slug":"errpreauthkeynotfound","errorCode":"ErrPreAuthKeyNotFound","errorMessage":"auth-key not found: %w","messagePattern":"auth-key not found: %w","errorType":"error_code","errorClass":"gorm.ErrRecordNotFound","httpStatus":401,"severity":"error","filePath":"hscontrol/db/preauth_keys.go","lineNumber":21,"sourceCode":"import (\n\t\"errors\"\n\t\"fmt\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/juanfont/headscale/hscontrol/types\"\n\t\"golang.org/x/crypto/bcrypt\"\n\t\"gorm.io/gorm\"\n\t\"tailscale.com/util/rands\"\n\t\"tailscale.com/util/set\"\n)\n\nvar (\n\t// ErrPreAuthKeyNotFound wraps gorm.ErrRecordNotFound so an unknown or\n\t// deleted key is treated as a missing record by callers, which the\n\t// registration handler maps to a 401 rather than a raw server error.\n\tErrPreAuthKeyNotFound          = fmt.Errorf(\"auth-key not found: %w\", gorm.ErrRecordNotFound)\n\tErrPreAuthKeyExpired           = errors.New(\"auth-key expired\")\n\tErrSingleUseAuthKeyHasBeenUsed = errors.New(\"auth-key has already been used\")\n\tErrUserMismatch                = errors.New(\"user mismatch\")\n\tErrPreAuthKeyACLTagInvalid     = errors.New(\"auth-key tag is invalid\")\n)\n\n// validateACLTags deduplicates, sorts, and checks that every tag carries the\n// \"tag:\" prefix. Shared by the pre-auth-key and OAuth credential paths so both\n// enforce the same tag shape.\nfunc validateACLTags(tags []string) ([]string, error) {\n\ttags = set.SetOf(tags).Slice()\n\tslices.Sort(tags)\n\n\tfor _, tag := range tags {\n\t\tif !strings.HasPrefix(tag, \"tag:\") {\n\t\t\treturn nil, fmt.Errorf(\n\t\t\t\t\"%w: '%s' did not begin with 'tag:'\",\n\t\t\t\tErrPreAuthKeyACLTagInvalid,","sourceCodeStart":3,"sourceCodeEnd":39,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/db/preauth_keys.go#L3-L39","documentation":"ErrPreAuthKeyNotFound wraps gorm.ErrRecordNotFound: a pre-auth key lookup (by key string) matched no row — unknown, deleted, or malformed so it derives a nonexistent key. The code comment notes the registration handler maps this to a 401 rather than a server error, so callers must test errors.Is rather than string matching.","triggerScenarios":"Registering a node with an expired-and-deleted key, a typo'd key, or one from a different headscale instance; key revoked/expired and cleaned up.","commonSituations":"Users pasting keys with missing characters or extra whitespace; stale keys after server rebuild; environments pointing at the wrong control server.","solutions":["Check errors.Is(err, db.ErrPreAuthKeyNotFound) and return 401 to the client","Generate a fresh pre-auth key and re-register the node","Verify the key string is copied whole (no line-wrap truncation in terminals)"],"exampleFix":"// before\nif err != nil {\n\treturn err\n}\n\n// after\nif errors.Is(err, db.ErrPreAuthKeyNotFound) {\n\treturn ErrUnauthorized\n}","handlingStrategy":"try-catch","validationCode":"// Optional shape check before sending registration\nif !strings.HasPrefix(userKey, \"nodekey-\") {\n\treturn fmt.Errorf(\"key does not look like a pre-auth key\")\n}","typeGuard":"func isPreAuthKeyNotFound(err error) bool {\n\treturn errors.Is(err, db.ErrPreAuthKeyNotFound) ||\n\t\terrors.Is(err, gorm.ErrRecordNotFound)\n}","tryCatchPattern":"if err := db.UsePreAuthKey(key, node); err != nil {\n\tif errors.Is(err, db.ErrPreAuthKeyNotFound) {\n\t\treturn ErrUnauthorized // 401, matching the registration handler\n\t}\n\treturn err\n}","preventionTips":["Return 401, not 500, for unknown keys","Copy keys with 'copy' buttons or clipboards, not selection drag","Track key creation/expiry so users register with fresh keys"],"tags":["go","preauth-key","authentication","sentinel-error","not-found"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}