How Do Push Apps Register Service Worker & Save FCM Token From Merchant Storefront?

Hi everyone,

I’m building a Shopify app for web push notifications, similar to apps like PushOwl, PushBot, etc.

MAIN ISSUE (CROSS-ORIGIN ERROR):
We are trying to ask for push notification permission directly on the merchant’s storefront, but the request and service worker come from our app or CDN domain, which causes Chrome to block it due to cross-origin restrictions.
Browsers require the service worker (sw.js) to be served from the same origin (for example: merchant.myshopify.com/sw.js), but Shopify does not allow apps to place files at the root directory.


WHAT I’M TRYING TO DO:

  • Ask for notification permission directly on the merchant storefront
  • Register a service worker (sw.js) from the same origin as the merchant store (as required by browser security rules)
  • Subscribe the user to push notifications using Firebase Cloud Messaging (FCM)
  • Save the FCM token to Firestore automatically without asking the merchant to manually add anything

WHAT’S BLOCKING US:

  • Shopify does not allow apps to upload or place files directly to the root of the merchant domain (like /sw.js)
  • Because of this, browser blocks the service worker registration due to CORS / origin mismatch
  • And this breaks the entire token saving and permission flow

ERROR WE ARE GETTING:
Failed to register a ServiceWorker for scope (…) with script (…/sw.js): A bad HTTP response code (404) was received when fetching the script


WHAT WE ALREADY TRIED:

  • Serving sw.js using Shopify App Proxy → this is blocked because the response returns HTML, not application/javascript
  • Hosting sw.js on our own CDN → this is blocked by browser due to cross-origin security rules
  • Using iframe or blob-based service worker registration → blocked due to Shopify CSP (Content Security Policy) and browser restrictions
  • Uploading sw.js to the theme using Admin API under /assets/sw.js → upload works, but browser still blocks it because it’s not at root level (still cross-origin)

WHAT WE NEED HELP WITH:

  • How do apps like PushOwl, PushBot, PushRocket, etc. solve this issue automatically without asking merchants to upload anything manually?
  • Is there any documented or safe Shopify-supported way to serve sw.js from the root of the merchant’s domain (merchant.myshopify.com/sw.js)?
  • What’s the correct way to register a service worker on the storefront, get the FCM token, and send it to our backend without triggering cross-origin or CORS errors?
  • Are there any known solutions or best practices that other apps are using in 2025 to get around this limitation?

We just want to make this push notification setup work 100% automatically like other apps — with no merchant involvement.
Everything else in the app is working perfectly. Any help, clarification, or guidance would be really appreciated.

Thanks !

Same, did you find any solution for this problem

Also stuck with it for a long time, but managed to find a solution.

Here is the flow:

  1. Create firebase-messaging-sw.js and make sure that it will be available in the folder with your site assests after the deploy (in my case it is larevel and public/build/assets folder). Here is my file, just in case (pretty standard)
// Give the service worker access to Firebase Messaging.
// Note that you can only use Firebase Messaging here. Other Firebase libraries
// are not available in the service worker.
// Replace 10.13.2 with latest version of the Firebase JS SDK.
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');

// Initialize the Firebase app in the service worker by passing in
// your app's Firebase config object.
// https://firebase.google.com/docs/web/setup#config-object
firebase.initializeApp({
  apiKey: "",
  authDomain: "",
  projectId: "",
  storageBucket: "",
  messagingSenderId: "",
  appId: "",
  measurementId: "",
});

// Handle notification click event
self.addEventListener('notificationclick', (event) => {
    // Close the notification
    event.notification.close();
    
    // Get the URL from notification data
    const urlToOpen = event.notification.data?.url;
    
    event.waitUntil(
        clients.matchAll({type: 'window'}).then((windowClients) => {
        // If no matching tab is found, open a new one
        if (clients.openWindow) {
                return clients.openWindow(urlToOpen);
            }
        })
    );
});

// Retrieve an instance of Firebase Messaging so that it can handle background
const messaging = firebase.messaging();

messaging.onBackgroundMessage((payload) => {
    // Extract all data you want to preserve
    const notificationData = {
        url: payload.data?.url,
        // Include any other data you need
        ...payload.data
    };

    // Customize notification here
    const notificationTitle = payload.data?.title || 'Notification';
    const notificationOptions = {
        body: payload.data?.body,
        icon: payload.data?.icon,
        data: notificationData
    };
    
    self.registration.showNotification(notificationTitle, notificationOptions);
});
  1. Create app proxy web point to return firebase-messaging-sw.js file. The url should look like https://my-shopify-store.myshopify.com/your-proxy-app/your-endpoint-for-firebase-messageing-sw.
    Here is my example

    public function getServiceWorker(Request $request)
    {
        $remoteUrl = match(config('app_stage')) {
            'staging'     => 'https://your-assets-url/build/assets/firebase-messaging-sw.js',
            'production' => 'https://your-assets-url/build/assets/firebase-messaging-sw.js'
        };
    
        $response  = Http::get($remoteUrl);
        
        if ($response->successful()) {
            return response($response->body(), 200)
                ->header('Content-Type', 'application/javascript');
        }
        
        abort(404, 'Remote script not found');
    }
    
  2. Add firebase.js file with client logic and import it to your frontend code (as any other js file), here is my example.

import { initializeApp } from "firebase/app";
import { getMessaging, onMessage, getToken } from "firebase/messaging";

import { detectPlatform } from './utils/common';

function getEnv(url) {
  const match = url.match(/url-match/);
  if (!match) return null;

  return match[1] === 'smth' ? 'production' : 'staging';
}

const PROXY_URL = "...";

// Unregister any existing service workers
async function unregisterServiceWorkers() {
    const registrations = await navigator.serviceWorker.getRegistrations();
    
    console.log(registrations);
    
    for (let registration of registrations) {
        await registration.unregister();
        console.log('Unregistered old service worker:', registration.scope);
    }
}

// Client-side code (NOT in service worker)
async function registerServiceWorker() {
    try {
        const registration = await navigator.serviceWorker.register(`${PROXY_URL}get-service-worker?v=${Date.now()}`, {
            updateViaCache: 'none', // Bypass HTTP cache
            scope: PROXY_URL // Explicit scope
        });
        
        // WAIT until service worker is actually active
        if (registration.installing) {
            await new Promise(resolve => {
                registration.installing.addEventListener('statechange', (e) => {
                    if (e.target.state === 'activated') {
                        resolve();
                    }
                });
            });
        }
        
        return registration;
    } catch (error) {
        console.error('SW registration failed:', error);
        throw error;
    }
}

const firebaseConfig = {
    apiKey: "",
    authDomain: "",
    projectId: "",
    storageBucket: "",
    messagingSenderId: "",
    appId: "",
    measurementId: "",
};

if (import.meta.env.PROD && 'serviceWorker' in navigator) {
    const app = initializeApp(firebaseConfig);
    const messaging = getMessaging(app);

    window.safariWebPushRegistered = false;
    
    const platform = detectPlatform();
    
    const registerSW = () => registerServiceWorker()
        .then(async (registration) => {
            console.log('Service Worker registered with scope:', registration.scope);

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                throw new Error('Permission not granted for Notification');
            }

            const token = await getToken(messaging, {
                vapidKey: '',
                serviceWorkerRegistration: registration, // ✅ must pass this manually
            });


            window.fcmToken= token;
            // Save token to server if needed, ajax to your update-fcm-token endpoint
            
            // console.log(response);
            
            window.safariWebPushRegistered= true;
        })
        .catch((err) => {
            console.error('Error during service worker registration or token retrieval:', err);
        });
    
    // For MAC ISO it's only possible to register on user interaction (at least I didn't find other way)
    if (platform.isIOS && platform.isDesktop) {
        document.addEventListener("mousedown", (event) => {
            if (window.safariWebPushRegistered) return;
            
            // alert('This is mousedown event on IOS DESKTOP');
            // console.log('This is mousedown event on IOS DESKTOP');
            registerSW();
        });
    } else {
        // alert('This is run from NON ios device');
        registerSW();
    }

    // Handle notification click event
    addEventListener('notificationclick', (event) => {
        // Close the notification
        event.notification.close();
        // Get the URL from notification data
        const urlToOpen = event.notification.data?.url;
                
        event.waitUntil(
            clients.matchAll({type: 'window'}).then((windowClients) => {
            // If no matching tab is found, open a new one
            if (clients.openWindow) {
                    return clients.openWindow(urlToOpen);
                }
            })
        );
    });

    onMessage(messaging, (payload) => {
        // console.log("Foreground message received: ", payload);        
        // Extract all data you want to preserve
        const notificationData = {
            url: payload.data?.url || `${window.location.origin + PROXY_URL}`,
            // Include any other data you need
            ...payload.data
        };
        
        const notificationTitle = payload.data?.title || 'Notification';
        const notificationOptions = {
            body: payload.data?.body,
            icon: payload.data?.icon,
            data: notificationData
        };
        new Notification(notificationTitle, notificationOptions);
    });
} else {
    console.warn('Service workers are not supported in this browser');
}

That should do the trick (at least did it for me)

2 Likes

Thanks for posting this approach @excite !!

Do you know about temp token when a user allow notifications from different browsers instead of chrome we are recieving temp token, which are useless , do you know the solution how to fix it