juanfont/headscale · error

failed to parse oauth client secret

Error message

failed to parse oauth client secret

What it means

Sentinel in hscontrol/db/oauth.go rejecting a malformed OAuth client secret during lookup/verification. Client credentials have a fixed shape: hskey-oauthcli- style prefix with a 12-char prefix and 64-char secret (and a legacy shorter format accepted). Parsing fails before any Argon2id hash comparison happens.

Source

Thrown at hscontrol/db/oauth.go:38

const (
	// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
	// is the public, indexed lookup key (the analogue of an API key's prefix) and
	// is embedded in the secret so the token endpoint can derive it. The prefix
	// itself lives in the types package ([types.OAuthClientPrefix]).
	oauthClientIDLength     = 12
	oauthClientSecretLength = 64

	// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
	// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
	// middleware dispatch a scoped token from an all-access admin key alone.
	accessTokenPrefixLength = 12
	accessTokenSecretLength = 64
)

var (
	ErrOAuthClientNotFound      = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
	ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
	ErrOAuthClientRevoked       = errors.New("oauth client revoked")

	ErrAccessTokenNotFound      = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
	ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
	ErrAccessTokenExpired       = errors.New("oauth access token expired")
	ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")

	errSecretHashMalformed = errors.New("malformed secret hash")
	errSecretMismatch      = errors.New("secret does not match hash")
)

// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
	argon2Time    = 2
	argon2Memory  = 19 * 1024
	argon2Threads = 1

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Re-copy the client secret exactly as shown at creation; trim whitespace when loading it
  2. Confirm the credential kind matches the endpoint (client secret vs hskey-oauthtok access token)
  3. If the secret was regenerated, update all consumers — old secrets are revoked
Defensive patterns

Strategy: type-guard

Validate before calling

const oauthSecretPrefix = "hskey-oauthcli-" // per repo constants
if !strings.HasPrefix(secret, oauthSecretPrefix) {
    return errors.New("not an OAuth client secret")
}

Type guard

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

Try / catch

client, err := db.GetOAuthClientBySecret(secret)
if err != nil {
    if errors.Is(err, db.ErrOAuthClientFailedToParse) {
        return unauthorized("malformed OAuth client secret") // 401, no retry
    }
    if errors.Is(err, db.ErrOAuthClientNotFound) { /* also 401 */ }
    return err
}

Prevention

When it happens

Trigger: Passing an empty string, a secret without the expected prefix, one whose prefix/secret segments have the wrong length, or a credential of another type (API key, auth-key, access token) to the OAuth client secret verifier.

Common situations: Automation storing the client secret with a trailing newline or truncated by shell word-splitting; pasting an oauth access token where the client secret belongs.

Understand the failure class

Related errors


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