juanfont/headscale · error

failed to parse ApiKey

Error message

failed to parse ApiKey

What it means

Sentinel from hscontrol/db/api_key.go used to reject malformed API key strings before any database lookup. Valid keys have the shape hskey-api-<prefix(12)>-<secret(64)>; a legacy 32-char format is also accepted. Wrap sites (api_key.go:170-246) add detail such as 'invalid display prefix format', 'prefix too short', 'prefix contains invalid characters', or a legacy length mismatch.

Source

Thrown at hscontrol/db/api_key.go:26

	"github.com/juanfont/headscale/hscontrol/types"
	"golang.org/x/crypto/bcrypt"
	"gorm.io/gorm"
	"tailscale.com/util/rands"
)

const (
	apiKeyPrefix       = "hskey-api-" //nolint:gosec // This is a prefix, not a credential
	apiKeyPrefixLength = 12
	apiKeyHashLength   = 64

	// Legacy format constants.
	legacyAPIPrefixLength = 7
	legacyAPIKeyLength    = 32
)

var (
	ErrAPIKeyFailedToParse    = errors.New("failed to parse ApiKey")
	ErrAPIKeyGenerationFailed = errors.New("failed to generate API key")
	ErrAPIKeyExpired          = errors.New("API key expired")
)

// CreateAPIKey creates a new [types.APIKey] in a user, and returns it.
func (hsdb *HSDatabase) CreateAPIKey(
	expiration *time.Time,
) (string, *types.APIKey, error) {
	// Generate public prefix (12 chars)
	prefix := rands.HexString(apiKeyPrefixLength)

	// Generate secret (64 chars)
	secret := rands.HexString(apiKeyHashLength)

	// Full key string (shown ONCE to user)
	keyStr := apiKeyPrefix + prefix + "-" + secret

	// bcrypt hash of secret

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check the key starts with hskey-api- followed by 12 hex chars, a dash, and 64 hex chars — re-copy it from `headscale apikeys create` output
  2. Strip whitespace/newlines when loading the key in scripts (e.g. tr -d '\n')
  3. Confirm you are not passing a pre-auth key (authkey-...) or oauth token to the API-key code path
  4. If the key is legacy format, verify it is exactly 32 characters after the legacy prefix

Example fix

// before
key := string(rawFileBytes) // trailing newline included
_, ak, err := hsdb.GetAPIKey(key)

// after
key := strings.TrimSpace(string(rawFileBytes))
if !strings.HasPrefix(key, "hskey-api-") {
    return fmt.Errorf("not an API key: %w", db.ErrAPIKeyFailedToParse)
}
_, ak, err := hsdb.GetAPIKey(key)
Defensive patterns

Strategy: type-guard

Validate before calling

// verify shape before calling the DB layer
func looksLikeAPIKey(s string) bool {
    parts := strings.Split(s, "-")
    return len(parts) == 4 && parts[0] == "hskey" && parts[1] == "api" &&
        len(parts[2]) == 12 && len(parts[3]) == 64 && isHex(parts[2]+parts[3])
}

Type guard

func isAPIKey(s string) bool {
    rest, ok := strings.CutPrefix(s, "hskey-api-")
    if !ok {
        return false
    }
    prefix, secret, found := strings.Cut(rest, "-")
    return found && len(prefix) == 12 && len(secret) == 64
}

Try / catch

_, ak, err := hsdb.GetAPIKey(keyStr)
if err != nil {
    if errors.Is(err, db.ErrAPIKeyFailedToParse) {
        // reject the credential early; do not retry — the string is malformed
        return echo.NewHTTPError(http.StatusUnauthorized, "malformed API key")
    }
    return err
}

Prevention

When it happens

Trigger: Calling GetAPIKey / verification helpers with a string that is empty, lacks the hskey-api- prefix, has a prefix shorter than 12 chars, contains non-hex characters, or is a truncated/edited legacy key. Also triggered by passing a key of a different kind (e.g. an auth-key or oauth token) to the API-key verifier.

Common situations: Shell quoting mangled the key (truncated at a newline or dash-split); copy/paste lost characters; using an oauth access token (hskey-oauthtok-...) where an admin API key is expected; scripts reading the key from a file that includes a trailing newline.

Understand the failure class

Related errors


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