juanfont/headscale · warning · ErrAPIKeyFailedToParse

failed to parse ApiKey: prefix contains invalid characters

Error message

failed to parse ApiKey: prefix contains invalid characters

What it means

Returned by ParseAPIKeyPrefix when the first 12 characters after 'hskey-api-' fail the isValidBase64URLSafe check. Headscale generates prefixes from a base64 URL-safe alphabet, so invalid characters (spaces, '+', '/', control chars, shell-expanded symbols) mean the string is not a headscale-generated key.

Source

Thrown at hscontrol/db/api_key.go:182

	// If it starts with the API key prefix, parse it
	if strings.HasPrefix(displayPrefix, apiKeyPrefix) {
		// Remove the "hskey-api-" prefix
		_, remainder, found := strings.Cut(displayPrefix, apiKeyPrefix)
		if !found {
			return "", fmt.Errorf("%w: invalid display prefix format", ErrAPIKeyFailedToParse)
		}

		// Extract just the first 12 characters (the actual prefix)
		if len(remainder) < apiKeyPrefixLength {
			return "", fmt.Errorf("%w: prefix too short", ErrAPIKeyFailedToParse)
		}

		prefix := remainder[:apiKeyPrefixLength]

		// Validate it's base64 URL-safe
		if !isValidBase64URLSafe(prefix) {
			return "", fmt.Errorf("%w: prefix contains invalid characters", ErrAPIKeyFailedToParse)
		}

		return prefix, nil
	}

	// For legacy 7-character prefixes or other formats, return as-is
	return displayPrefix, nil
}

// validateAPIKey validates an API key and returns the key if valid.
// Handles both new (hskey-api-{prefix}-{secret}) and legacy (prefix.secret) formats.
func validateAPIKey(db *gorm.DB, keyStr string) (*types.APIKey, error) {
	// Validate input is not empty
	if keyStr == "" {
		return nil, ErrAPIKeyFailedToParse
	}

	// Check for new format: hskey-api-{prefix}-{secret}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Re-copy the key verbatim from its source ('headscale apikeys create' output) without shell interpolation.
  2. Quote the key in shell usage: use single quotes to prevent variable expansion.
  3. If the key was stored URL-encoded, decode it once before passing.

Example fix

// before
_, err := db.ParseAPIKeyPrefix(os.Args[1]) // unquoted $ or ! chars got expanded

// after
// pass the key single-quoted on the command line:
//   headscale ... --apikey 'hskey-api-XXXXXXXXXXXX-YYYY...'
_, err := db.ParseAPIKeyPrefix(key)
Defensive patterns

Strategy: validation

Validate before calling

var base64urlRe = regexp.MustCompile(`^[A-Za-z0-9_-]{12,}$`)
if strings.HasPrefix(display, "hskey-api-") && !base64urlRe.MatchString(strings.TrimPrefix(display, "hskey-api-")) {
    return errors.New("API key contains invalid characters; re-copy it verbatim")
}

Type guard

func looksLikeBase64URLSafe(s string) bool {
    for _, r := range s {
        if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
            return false
        }
    }
    return true
}

Try / catch

if _, err := db.ParseAPIKeyPrefix(display); err != nil && errors.Is(err, db.ErrAPIKeyFailedToParse) {
    // input is not a headscale-generated key; do not retry, re-collect the value
}

Prevention

When it happens

Trigger: Calling ParseAPIKeyPrefix with 'hskey-api-' followed by characters outside [A-Za-z0-9_-]: a key corrupted by shell expansion ($, !), URL-decoded with '+' or '/' inside, or a foreign token pasted with the headscale prefix.

Common situations: Keys pasted through terminals that mangle special characters; double-URL-encoding/decoding; users prefixing another system's token with 'hskey-api-'.

Understand the failure class

Related errors


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