FCM Push from Apps Script: JWT Encoding and Recipient Management

한국어로 읽기

TECH INSIGHT / EXPLAINED

Push delivery involves two different tokens. An OAuth access token authorizes the server to send; an FCM registration token identifies a receiving app instance. Keeping them separate makes authentication failures and recipient failures easier to handle correctly.

Three adults explain signed credentials, short-lived authorization, and mobile notification retries
AI-generated conceptual illustration. Three adults explain signed credentials, short-lived authorization, and mobile notification retries

Local execution of the actual project

These captures show the existing project UI or functions executed locally with non-private sample inputs. Each caption states the scope of the execution.

The original GAS console with only the app-notification option toggled locally, separately from Telegram. No recipient token or credentials were supplied. This runs the notification UI only; it does not demonstrate JWT generation, OAuth exchange, or successful FCM delivery.
The original GAS console with only the app-notification option toggled locally, separately from Telegram. No recipient token or credentials were supplied. This runs the notification UI only; it does not demonstrate JWT generation, OAuth exchange, or successful FCM delivery.

Calling HTTP v1 from Apps Script

Apps Script can call FCM HTTP v1 through UrlFetchApp. Signing a service-account JWT manually is one implementation option. If a maintained authentication library or a separate sending service is available, compare its maintenance burden with manual cryptographic handling.

The service-account flow creates an RS256 header and claims, exchanges the signed JWT at https://oauth2.googleapis.com/token, and obtains a short-lived access token. Use the https://www.googleapis.com/auth/firebase.messaging scope and the token endpoint as aud. iat and exp use seconds; exp must be no more than one hour after iat. The scope and appropriate IAM permission on the target project are both necessary.

Remove Base64url padding

Encode UTF-8 bytes with URL-safe Base64 and remove trailing = padding for each JWT segment. This fragment assumes that a claims object has already been prepared. It constructs signing input without using a key or sending a request.

function base64urlUtf8(text) {
  return Utilities.base64EncodeWebSafe(
    text, Utilities.Charset.UTF_8
  ).replace(/=+$/, '');
}
// JWT signing input only; no key or network request here.
const headerPart = base64urlUtf8(JSON.stringify({alg: 'RS256', typ: 'JWT'}));
const claimPart = base64urlUtf8(JSON.stringify(claims));
const signingInput = headerPart + '.' + claimPart;

Sign the exact input bytes and append the signature bytes encoded as unpadded Base64url. Check the token exchange's HTTP status, JSON errors, access_token, and expires_in. Cache with an expiry margin and coordinate simultaneous refreshes with a lock. Never print complete JWTs or tokens in debugging logs.

Key storage is separate from encoding

Correctly parsing the service-account JSON restores escaped newlines in private_key. Base64 wrapping can simplify a storage path, but it is neither encryption nor a guarantee against corruption. If using Script Properties, assume project editors can access the key. Restrict access, plan rotation and revocation, and choose secret management appropriate to the deployment.

Register recipients through authenticated requests

Send the client SDK's registration token through an authenticated POST from the signed-in user. URL query parameters can persist in history and logs. Store the user, app instance, token, and refresh time together; clear associations on logout or unlinking. Do not overwrite a multi-device user's registrations with one token.

Check the actual execution principal and service user instead of relying solely on User Properties scope. Never confuse the server's OAuth token with a client registration token or return server credentials to the browser.

Accepted does not mean displayed

The send endpoint is https://fcm.googleapis.com/v1/projects/PROJECT_ID/messages:send. muteHttpExceptions lets code inspect error HTTP responses; it does not turn them into success. Remove a registration on UNREGISTERED, investigate authentication and permission errors, and use delayed retries for transient and quota errors according to official guidance.

A returned message ID does not confirm display on a device. Keep important events in the application's own history with event_id so they can be retrieved. High priority does not guarantee immediate delivery. Minimize sensitive account information on lock screens. Even with Telegram as another path, a shared backend failure can stop both channels.

Recipient linking and delivery queue design

References

Content and examples reviewed: 2026-09-29

댓글