You're right -- it's not secure to distribute the service account key. Even with just the minimal Pub/Sub Publisher
role, this would allow anyone who has a copy of the key to inject as much data as they wanted into Pubsub (billing you), and depending on how your subscribers use the data thus provided, it could open up an attack surface on the side of your subscribers. Likewise, you would want to plan for what to do should you need to rotate the key. I do not recommend you take this approach.
Given that you're not including the key with the app itself, you're left with finding a way to do something in a controlled server environment which also has access to the service account credentials.
One option is that you could create a Callable Cloud Function to do the Pubsub operation. The cloud function would reside securely on Google's servers, and could run as a service account no problem. This would then be called from your application.
Inside the function, you would need to validate that the request is allowed (since the function could still be called by anyone -- they're effectively public), and then do the Pubsub operation. Validation could involve checking the authentication of the user using the app, checking that the app is well formed, and even possibly implementing some form of rate limiting. This is likely to be a very lightweight function in practice.
Even with the most minimal request validation, this still restricts callers to doing exactly what the function has been written to do, as opposed to full access to the service account, which minimizes the attack surface to what basically any interaction with code on a web server might be, and it allows you to validate that the input is valid before you inject it into Pubsub -- thus protecting all downstream consumers.