See how copy works in NotiFixed

Push that turns up as your customer's app: per-tenant APNs and FCM credentials explained

Last reviewed 26 October 2026 · 5 min read

A push notification carries the name and icon of whichever app's credentials signed it. If your platform sends with your keys, every push says your name. To make it say your customer's name, you have to send with their APNs key and their Firebase project, which means storing one set of credentials per customer and picking the right one on every send.

The failure mode

A B2B platform ships mobile push. The first customer asks why notifications to their users say "Platform" instead of "Acme". The engineer discovers there is no setting for this: Apple and Google decide the sender from the credentials, and the credentials are the platform's. The honest options are to have each customer ship their own app build with the platform's push key baked in (so the push says Acme, but now the platform's key is in every customer's binary), or to collect each customer's own key and route sends through it.

Teams then look at their notification vendor and find per-tenant credentials behind an Enterprise contract. So they build it: a table of keys, a routing rule, and six months later a SENDER_ID_MISMATCH error nobody can explain because a token minted under Acme's Firebase project was sent through Globex's service account.

The fix: credentials are a property of the customer, not the platform

What each customer has to give you

PlatformCredentialWhere they get itWhat it identifies
iOS and macOS appsAPNs auth key (.p8), its key id, the team id, and the app's bundle idApple Developer account, Certificates, Identifiers & Profiles, Keysthe team; a team-scoped key signs for all that team's apps
Android apps, and web push sent through FCMFirebase service-account JSON for their Firebase projectFirebase console, Project settings, Service accountsthe Firebase project; tokens are only valid inside it
Web push without FCM (Chrome, Firefox, Edge, Safari 16 and later)a VAPID key pairgenerated once; the public key goes in the web appthe sender, for the browser's push service

The .p8 key is the pleasant one: unlike the old push certificates it does not expire, and a team-scoped key covers every app on the team. The Firebase service account is the sharp one: device tokens are scoped to the project that issued them, so a token from Acme's app can only ever be sent through Acme's credentials.

Storing them

customer-push-credentials.sql

create table customer_push_credentials (
  customer       text not null,
  platform       text not null check (platform in ('apns','fcm','vapid')),
  bundle_or_project text not null,          -- APNs bundle id or Firebase project id
  key_id         text,                       -- APNs only
  team_id        text,                       -- APNs only
  secret_enc     bytea not null,             -- the .p8 or service-account JSON, encrypted
  key_version    int not null,               -- which KMS key encrypted it
  created_at     timestamptz not null default now(),
  revoked_at     timestamptz,
  primary key (customer, platform)
);

Encrypt the secret with a key your application code can use but cannot read back out of the KMS (envelope encryption), and keep the plaintext in memory only for the duration of signing. Record key_version so you can rotate the KMS key without re-collecting credentials. Never log the secret, and never return it from any API, including to the customer who uploaded it: they have their own copy.

Tokens remember where they came from

The mistake that produces SENDER_ID_MISMATCH is storing device tokens without the customer. Store the token with the customer and platform it was registered under, and refuse a send that would cross the line.

push-tokens.sql

create table push_tokens (
  token       text primary key,
  recipient   text not null,
  customer    text not null,                 -- the project/team this token belongs to
  platform    text not null,
  created_at  timestamptz not null default now()
);

send-push.ts

async function sendPush(notificationId: string, recipient: string, customer: string, payload: Payload) {
  const tokens = await db.query(
    "select token, platform from push_tokens where recipient = $1 and customer = $2", [recipient, customer]);
  for (const { token, platform } of tokens) {
    const creds = await credentials.get(customer, platform);   // decrypted in memory, cached briefly
    if (!creds) { await log.suppressed(notificationId, token, "no_credentials",
      "This customer has not added push credentials yet. Feed and web push were sent."); continue; }
    const res = platform === "apns" ? await apns.send(creds, token, payload, notificationId)
                                     : await fcm.send(creds, token, payload, notificationId);
    await log.verdict(notificationId, token, platform, customer, res);   // see the delivery-log guide
  }
}

The customer in the query comes from the send request, not from the user. A user who exists under two of your customers (a consultant with two client accounts) has two sets of tokens, and each customer's sends reach only the devices registered under that customer.

Signing per customer

APNs wants a JWT signed with the .p8 key, with iss set to the team id and kid to the key id. Apple asks you to refresh it no more than once every 20 minutes and no less than once every 60, so cache one token per customer and key id for somewhere in between. FCM wants an OAuth2 access token from the service account; cache one per customer project for its lifetime, about an hour. Both caches are per customer: a bug that shares a cache across customers is exactly the cross-tenant error you are trying to avoid.

token-caches.ts

const apnsJwt = memo((customer: string) => signApnsJwt(creds(customer, "apns")), { ttl: 50 * 60 });
const fcmToken = memo((customer: string) => googleAccessToken(creds(customer, "fcm")), { ttl: 55 * 60 });

Set the apns-topic header to the customer's bundle id on every APNs request. A token from one customer's app sent with another customer's bundle id fails with DeviceTokenNotForTopic, the APNs twin of the Firebase mismatch.

Onboarding a customer

The technical part is a form with three uploads. The human part is that most of your customers' mobile teams have never exported a .p8 or a service-account file, and the failure modes (a key without push capability, a service account from the wrong project, a bundle id with a typo) all look like "push doesn't work". Validate at upload: sign a test JWT, request an FCM access token, and send one silent push to a test token if the customer provides one. Show the result in words.

What this does not fix

Per-customer credentials make the push come from the customer's app. They do not make the push come from the customer's brand inside your single app: if all customers' users install one app of yours, the push is from that app, whatever credentials you hold. Multi-tenant branding on push requires a per-customer app (or a white-label build per customer); this guide is for platforms that have one.

How NotiFixed does it

In NotiFixed, each of your customers' push goes out through their own Apple and Google credentials, on every plan, and we set them up with you: a 45-minute call with the customer's mobile team, so nobody has to learn what a service-account file is from a forum post. NotiFixed holds the keys and uses them only for that customer's sends.

Their app registers each device with POST /v1/me/devices when a user signs in and removes it with DELETE /v1/me/devices on sign-out. A customer whose keys are not in yet still gets the in-app feed from day one. When a push fails, the delivery log says what it means in words: an authentication error is the credentials, not the phone.

Further reading

Send it once.
Land it everywhere.