juanfont/headscale · error

invalid oauth client secret

Error message

invalid oauth client secret: %w

What it means

AuthenticateOAuthClient found the client row but verifySecret rejected the presented secret. The wrapped error is either errSecretMismatch (argon2 hash comparison failed — wrong secret) or errSecretHashMalformed (stored hash is not a valid PHC string — corrupted data or legacy format). Revocation is checked after, so this error specifically means the credential itself failed.

Solutions

  1. Regenerate the client secret and update the consumer
  2. Check the stored hash round-trips: it must look like $argon2id$v=19$m=...$salt$hash with valid base64
  3. Audit for manual writes to the oauth_clients table

Example fix

// before
client, err := hsdb.AuthenticateOAuthClient(secret)
if err != nil {
	log.Fatal(err)
}

// after
if err != nil {
	var msg string
	if errors.Is(err, db.ErrOAuthClientNotFound) {
		msg = "unknown client"
	} else {
		msg = "invalid client credentials"
	}
	http.Error(w, msg, http.StatusUnauthorized)
	return
}
Defensive patterns

Strategy: try-catch

Type guard

func isOAuthCredentialRejected(err error) bool {
	return err != nil && !errors.Is(err, db.ErrOAuthClientNotFound)
}

Try / catch

if _, err := hsdb.AuthenticateOAuthClient(secret); err != nil {
	switch {
	case errors.Is(err, db.ErrOAuthClientNotFound):
		return errUnknownClient
	case errors.Is(err, db.ErrOAuthClientRevoked):
		return errRevokedClient
	default: // includes invalid secret and malformed hash
		return errBadCredentials
	}
}

Prevention

When it happens

Trigger: Wrong or rotated client secret; secret copied with whitespace/newline; SecretHash column corrupted by manual edits or a truncated column type.

Common situations: Stale secret in CI/CD variables after rotation; database dumped/restored with encoding loss breaking the base64 in the hash.

Related errors


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

Appendix: source

Thrown at hscontrol/db/oauth.go:216

	}

	clientID, secret, err := parsePrefixedKey(
		rest,
		oauthClientIDLength,
		oauthClientSecretLength,
		ErrOAuthClientFailedToParse,
	)
	if err != nil {
		return nil, err
	}

	var client types.OAuthClient
	if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr
		return nil, ErrOAuthClientNotFound
	}

	if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr
		return nil, fmt.Errorf("invalid oauth client secret: %w", err)
	}

	if client.Revoked != nil {
		return nil, ErrOAuthClientRevoked
	}

	return &client, nil
}

// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.
func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
	var client types.OAuthClient
	if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil {
		return nil, result.Error
	}

	return &client, nil
}

View on GitHub (pinned to 565fd254d0)