all writing

Push Notifications on iOS Without a Developer Account

· 5 min read ·
Push Notifications on iOS Without a Developer Account

For years, sending push notifications to iPhones meant paying $99/year for an Apple Developer account, wrestling with APNs certificates, or routing everything through Firebase. Most indie developers just gave up on iOS push entirely.

Since iOS 16.4, that’s no longer true. Safari now supports the Web Push API - the same standard that’s worked on Android and desktop browsers for years. One implementation, every platform. No Firebase. No app stores. Just web standards.

The catch? Your app needs to be a PWA. On iOS, users must “Add to Home Screen” first. But that’s a small price for free, universal push notifications.

How It Works

Three parties are involved: your server, the browser’s push service, and the user’s browser.

  1. User subscribes - browser generates a unique endpoint URL and encryption keys
  2. You store the subscription on your server
  3. When something happens, your server encrypts the message and POSTs it to the endpoint
  4. Browser’s push service delivers it, your service worker displays it

The best part? You never touch Firebase or any third-party service. Each browser vendor runs their own push service - Google, Apple, and Mozilla all maintain their own infrastructure. When a user subscribes, the browser gives you an endpoint URL like:

You just POST to whatever URL you get. The Web Push protocol is standardized - your code doesn’t care which browser it’s talking to.

Security

VAPID proves your server’s identity. You generate an ECDSA P-256 key pair once, sign each request with a JWT, and push services verify the signature before accepting the message.

Encryption ensures privacy. Your server derives a shared secret with the browser’s public key (ECDH), then encrypts the payload (AES-128-GCM). Push services can’t read your messages - only the subscriber’s browser can decrypt them.

Complexity is a choice

PWA Setup

Now for the implementation. Your app needs a web manifest for installability:

{
  "name": "My App",
  "short_name": "App",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

The service worker handles incoming notifications. It listens for push events and displays them, even when the app is closed:

self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? { title: 'Notification' };
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icon-192.png'
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow('/'));
});

Subscribing Users

When a user enables notifications, you request permission, subscribe to the PushManager with your VAPID public key, then send the subscription to your server:

async function subscribeToPush(vapidPublicKey: string) {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: vapidPublicKey
  });

  // Send endpoint and keys to your server
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription.toJSON())
  });

  return subscription;
}

The subscription contains an endpoint (the URL you’ll POST to) and keys (p256dh and auth for encryption).

Sending Notifications

Your server encrypts the payload using the subscriber’s keys, signs the request with your VAPID private key, and POSTs to the endpoint:

const response = await fetch(subscription.endpoint, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/octet-stream',
    'Content-Encoding': 'aes128gcm',
    'Authorization': `vapid t=${jwt}, k=${vapidPublicKey}`,
    'TTL': '86400'
  },
  body: encryptedPayload
});

// 410/404 means subscription expired - clean it up
if (response.status === 410 || response.status === 404) {
  await removeSubscription(subscription.endpoint);
}

The encryption (ECDH + HKDF + AES-GCM) and VAPID JWT signing are complex - this is the one place where the Web Push API earns its reputation. Use a library like web-push for Node.js, or if you’re feeling adventurous, implement it yourself using the Web Crypto API.

Yes, this is a real push notification on an iPhone

Platform Support

iOS Safari (16.4+): Works, but with restrictions:

Android: Works in Chrome, Firefox, Edge - no installation required.

Desktop: Chrome, Firefox, Edge, Safari - just works.

Conclusion

The Web Push API gives you something that wasn’t possible before iOS 16.4: free, private, universal push notifications without platform fees or third-party dependencies. Your messages are end-to-end encrypted, so push services can’t read them. And you write the code once for iOS, Android, and desktop.

The main limitation is the PWA requirement on iOS - users need to “Add to Home Screen” before they can receive notifications. For many apps, that’s an acceptable trade-off.

I built Pingflare using this exact approach. It’s an uptime monitoring service that sends push notifications when your sites go down - check out the repo if you want to see a complete implementation with the encryption and VAPID signing handled properly.

About Isala Piyarisi

Builder and platform engineer with a track record of shipping products from scratch and seeing them through to scale. Works across the full stack from kernel to user interface.

AI & Machine Learning

Builds AI infrastructure and local-first AI systems. Experience with PyTorch, ML pipelines, RAG architectures, vector databases, and GPU orchestration. Created Tera, a local-first AI assistant built with Rust. Passionate about privacy-preserving AI that runs on-device.

Technical Range

Work spans: AI Infrastructure (local LLMs, ML pipelines, RAG, PyTorch), Platform Engineering (Kubernetes, observability, service mesh, GPU orchestration), and Systems (eBPF, Rust, Go, Linux internals).

Founder Mindset

Founded and ran a gaming community for 6 years, building infrastructure that served thousands of users. Built observability tools now used by developers daily. Approaches problems end-to-end, from design to production to on-call. Prefers building solutions over talking about them.

Current Work

Senior Software Engineer at WSO2, building Choreo developer platform. Architected eBPF-powered observability processing 500GB/day. Led Cilium CNI migration on 10,000+ pod cluster. Speaker at Conf42, KCD, and cloud-native events.