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

  1. Prefix each tag with 'tag:' at creation time
  2. Validate tags in the UI/API layer before hitting the DB function
  3. 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

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.