juanfont/headscale · error · ErrPreAuthKeyACLTagInvalid
ErrPreAuthKeyACLTagInvalid
ErrPreAuthKeyACLTagInvalid
Error message
%w: '%s' did not begin with 'tag:'
What it means
validateACLTags rejects any tag lacking the literal 'tag:' prefix, wrapping ErrPreAuthKeyACLTagInvalid with the offending value in the message. It runs in both the pre-auth-key and OAuth client creation paths (the function is shared), enforcing Tailscale's ACL tag shape before anything is persisted. Deduplication and sorting happen first, so the error reports a normalized tag.
Source
Thrown at hscontrol/db/preauth_keys.go:37
// 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,
tag,
)
}
}
return tags, nil
}
func (hsdb *HSDatabase) CreatePreAuthKey(
uid *types.UserID,
reusable bool,
ephemeral bool,
expiration *time.Time,
aclTags []string,
) (*types.PreAuthKeyNew, error) {
return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKeyNew, error) {View on GitHub (pinned to 565fd254d0)
Solutions
- Prefix each tag with 'tag:' at creation time
- Validate tags in the UI/API layer before hitting the DB function
- Cross-check tag names against the policy file's tagOwners so nodes can actually claim them
Example fix
// before
key, err := hsdb.CreatePreAuthKey(&uid, false, false, nil, []string{"webserver"})
// after
key, err := hsdb.CreatePreAuthKey(&uid, false, false, nil, []string{"tag:webserver"}) Defensive patterns
Strategy: validation
Validate before calling
for i, t := range tags {
if !strings.HasPrefix(t, "tag:") {
tags[i] = "tag:" + t // or reject explicitly
}
}
if _, err := db.CreatePreAuthKey(&uid, false, false, nil, tags); err != nil {
return err
} Type guard
func areValidACLTags(tags []string) bool {
for _, t := range tags {
if !strings.HasPrefix(t, "tag:") {
return false
}
return true
} Try / catch
if _, err := db.CreatePreAuthKey(&uid, reuse, ephemeral, expiration, tags); err != nil {
if errors.Is(err, db.ErrPreAuthKeyACLTagInvalid) {
return fmt.Errorf("tags must start with 'tag:': got %v", tags)
}
return err
} Prevention
- Normalize tags at the input boundary (API/UI), not at the DB call
- Keep tag lists in sync with the policy file's tagOwners section
- Reject, don't auto-prefix, in admin surfaces so users learn the shape
When it happens
Trigger: Calling CreatePreAuthKey or OAuth client creation with tags like ["server"] or ["web-server"] instead of ["tag:server"]; automation feeding raw host roles as tags.
Common situations: Scripts translating cloud labels or Kubernetes labels directly into headscale tags; policy files that elsewhere use bare names, encouraging the same habit at key creation.
Related errors
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/6e21bf8d96dd058c.
Report an issue: GitHub.