juanfont/headscale · error · gorm.ErrRecordNotFound
ErrPreAuthKeyNotFound
ErrPreAuthKeyNotFound
Error message
auth-key not found: %w
What it means
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.
Source
Thrown at hscontrol/db/preauth_keys.go:21
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"tailscale.com/util/rands"
"tailscale.com/util/set"
)
var (
// ErrPreAuthKeyNotFound wraps gorm.ErrRecordNotFound so an unknown or
// deleted key is treated as a missing record by callers, which the
// registration handler maps to a 401 rather than a raw server error.
ErrPreAuthKeyNotFound = fmt.Errorf("auth-key not found: %w", gorm.ErrRecordNotFound)
ErrPreAuthKeyExpired = errors.New("auth-key expired")
ErrSingleUseAuthKeyHasBeenUsed = errors.New("auth-key has already been used")
ErrUserMismatch = errors.New("user mismatch")
ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid")
)
// validateACLTags deduplicates, sorts, and checks that every tag carries the
// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both
// enforce the same tag shape.
func validateACLTags(tags []string) ([]string, error) {
tags = set.SetOf(tags).Slice()
slices.Sort(tags)
for _, tag := range tags {
if !strings.HasPrefix(tag, "tag:") {
return nil, fmt.Errorf(
"%w: '%s' did not begin with 'tag:'",
ErrPreAuthKeyACLTagInvalid,View on GitHub (pinned to 565fd254d0)
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)
Example fix
// before
if err != nil {
return err
}
// after
if errors.Is(err, db.ErrPreAuthKeyNotFound) {
return ErrUnauthorized
} Defensive patterns
Strategy: try-catch
Validate before calling
// Optional shape check before sending registration
if !strings.HasPrefix(userKey, "nodekey-") {
return fmt.Errorf("key does not look like a pre-auth key")
} Type guard
func isPreAuthKeyNotFound(err error) bool {
return errors.Is(err, db.ErrPreAuthKeyNotFound) ||
errors.Is(err, gorm.ErrRecordNotFound)
} Try / catch
if err := db.UsePreAuthKey(key, node); err != nil {
if errors.Is(err, db.ErrPreAuthKeyNotFound) {
return ErrUnauthorized // 401, matching the registration handler
}
return err
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: Users pasting keys with missing characters or extra whitespace; stale keys after server rebuild; environments pointing at the wrong control server.
Related errors
- ErrOAuthClientNotFound
- ErrAccessTokenNotFound
- failed to parse ApiKey
- node not found
- failed to parse oauth client secret
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/b4bddf0627aadab3.
Report an issue: GitHub.