juanfont/headscale · error · ErrUserNotFound

%w: token %q

Error message

%w: token %q

What it means

Username.resolve searched all users by ProviderIdentifier, Email, and Name and found no match for the policy token. The username is well-formed (has '@') but does not correspond to any user in the database the policy was compiled against.

Source

Thrown at hscontrol/policy/v2/types.go:409

	// At parsetime, we require all usernames to contain an "@" character, if the
	// username token does not naturally do so (like email), the user have to
	// add it to the end of the username. We strip it here as we do not expect the
	// usernames to be stored with the "@".
	uTrimmed := strings.TrimSuffix(u.String(), "@")

	for _, user := range users {
		if user.ProviderIdentifier.Valid && user.ProviderIdentifier.String == uTrimmed {
			// Prioritize ProviderIdentifier match and exit early
			return user, nil
		}

		if user.Email == uTrimmed || user.Name == uTrimmed {
			potentialUsers = append(potentialUsers, user)
		}
	}

	if len(potentialUsers) == 0 {
		return types.User{}, fmt.Errorf("%w: token %q", ErrUserNotFound, u.String())
	}

	if len(potentialUsers) > 1 {
		return types.User{}, fmt.Errorf("%w: token %q found: %s", ErrMultipleUsersFound, u.String(), potentialUsers.String())
	}

	return potentialUsers[0], nil
}

func (u *Username) Resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
	return newResolvedAddresses(u.resolve(nil, users, nodes))
}

func (u *Username) resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
	var (
		ips  netipx.IPSetBuilder
		errs []error
	)

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Verify the exact identifier: run 'headscale users list' and compare email/name/ProviderIdentifier.
  2. Update the policy token to the current email or username.
  3. If the user should exist, recreate it or re-run OIDC login so the user row is populated.
  4. Prefer groups for stable policies so individual renames do not break ACLs.

Example fix

// before
{"src": ["bob@old.example.com"], "dst": ["web:80"]}

// after
{"src": ["bob@new.example.com"], "dst": ["web:80"]}
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-check the token against the user DB before compiling the policy.
if _, err := findUserByToken(users, token); err != nil {
    return fmt.Errorf("policy token %q matches no user; run 'headscale users list'", token)
}

Try / catch

user, err := username.Resolve(pol, users, nodes)
if err != nil {
    if errors.Is(err, v2.ErrUserNotFound) {
        // missing user: sync DB or update policy; do not silently continue
        return fmt.Errorf("policy references unknown user %q: %w", username, err)
    }
    return err
}

Prevention

When it happens

Trigger: Policy references 'bob@old.example.com' after the user's email changed; a user deleted via 'headscale users destroy'; a fresh database where tests reference users never created; ProviderIdentifier mismatch after IdP migration.

Common situations: Renaming users or changing the OIDC provider without updating policy files; policy files shared across environments (prod ACL in a test DB); typos in the domain part.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/ea09d278bcdc3c9a. Report an issue: GitHub.