November 25, 2025

Building a Real iOS Notification Service Extension: Sharing Auth State Across an App Group

Everything in my earlier post on foreground/background/terminated notifications still runs entirely inside the React Native app’s own process. A Notification Service Extension is a different thing altogether — it’s a separate binary, in a separate sandbox, that iOS launches on its own just to intercept a push before it’s shown, with a hard time limit to do something useful with it and hand it back.

What the extension is actually for

The push payload deliberately only carries an ID, not the real notification content — the same design as the foreground handler. For a background push, though, there’s no running app instance to fetch that content and rebuild the notification. That’s the extension’s whole job: intercept the raw push, make one authenticated API call, and rewrite the notification body before the OS displays it.

// NotificationService.swift
import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(
        _ request: UNNotificationRequest,
        withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
    ) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)

        guard let bestAttemptContent = bestAttemptContent else {
            contentHandler(request.content)
            return
        }

        let itemId = request.content.userInfo["item_id"] as? String ?? ""
        if itemId.isEmpty {
            contentHandler(bestAttemptContent)
            return
        }

        // ...fetch and mutate content, shown below
    }

    override func serviceExtensionTimeWillExpire() {
        // Apple gives this extension a strict, short time budget. If the network
        // call hasn't finished when it runs out, this is the last chance to hand
        // back *something* rather than let the notification show blank.
        if let handler = contentHandler, let content = bestAttemptContent {
            handler(content)
        }
    }
}

That serviceExtensionTimeWillExpire override isn’t optional in practice. If the extension’s process gets killed for running over budget without ever calling the content handler, the user doesn’t see an error — they see nothing, or a notification with no body at all.

The problem: the extension can’t see the app’s auth token

The extension runs as its own process. It doesn’t share memory, and it doesn’t have access to the main app’s AsyncStorage or keychain items the way a normal module inside the app would. But it needs an auth token and a tenant identifier to call the API and fetch the real notification content — data that only exists because the user is logged in, inside the main app.

The fix is an App Group — a shared container both the main app and the extension are entitled to read and write, backed by a shared UserDefaults suite:

// SharedStorage.swift — runs inside the main app
import Foundation
import React

@objc(SharedStorage)
class SharedStorage: NSObject {

  #if DEBUG
  let defaults = UserDefaults(suiteName: "group.com.example.member.dev")
  #else
  let defaults = UserDefaults(suiteName: "group.com.example.member")
  #endif

  @objc
  func saveAuthData(_ token: String, tenant: String, baseUrl: String) {
    defaults?.set(token, forKey: "auth_token")
    defaults?.set(tenant, forKey: "tenant")
    defaults?.set(baseUrl, forKey: "baseUrl")
    defaults?.synchronize()
  }

  @objc
  func getAuthToken(_ callback: RCTResponseSenderBlock) {
    callback([defaults?.string(forKey: "auth_token") ?? ""])
  }

  @objc
  static func requiresMainQueueSetup() -> Bool { return false }
}

With the Objective-C bridge to expose it to React Native:

// SharedStorage.m
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(SharedStorage, NSObject)

RCT_EXTERN_METHOD(saveAuthData:(NSString *)token tenant:(NSString *)tenant baseUrl:(NSString *)baseUrl)
RCT_EXTERN_METHOD(getAuthToken:(RCTResponseSenderBlock)callback)
RCT_EXTERN_METHOD(getTenant:(RCTResponseSenderBlock)callback)
RCT_EXTERN_METHOD(getBaseUrl:(RCTResponseSenderBlock)callback)

@end

And a thin JS wrapper so the rest of the app never touches NativeModules directly:

// utilities/fcmHelper/SharedStorage.js
import { NativeModules } from 'react-native';
const { SharedStorage } = NativeModules;

export function saveAuthData(token, tenant, baseUrl) {
  SharedStorage.saveAuthData(token, tenant, baseUrl);
}

export function getAuthToken() {
  return new Promise(resolve => {
    SharedStorage.getAuthToken(value => resolve(value));
  });
}

The app calls saveAuthData after login (and whenever the token refreshes), writing into the shared container. The extension — running minutes or hours later, in a completely different process, possibly while the app isn’t even running — reads from that same container when a push arrives.

Putting it together in the extension

Back in didReceive, once there’s an ID to work with, the extension reads the shared credentials and makes its own network call:

#if DEBUG
let defaults = UserDefaults(suiteName: "group.com.example.member.dev")
#else
let defaults = UserDefaults(suiteName: "group.com.example.member")
#endif

let token = defaults?.string(forKey: "auth_token") ?? ""
let tenant = defaults?.string(forKey: "tenant") ?? ""
let savedBaseUrl = defaults?.string(forKey: "baseUrl") ?? ""

guard !token.isEmpty, !tenant.isEmpty else {
    contentHandler(bestAttemptContent)
    return
}

let urlString = "\(savedBaseUrl)/\(tenant)/api/items/\(itemId)/notification"
guard let url = URL(string: urlString) else {
    contentHandler(bestAttemptContent)
    return
}

var req = URLRequest(url: url)
req.httpMethod = "GET"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

URLSession.shared.dataTask(with: req) { data, _, error in
    defer { contentHandler(bestAttemptContent) }

    guard error == nil, let data = data,
          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let description = json["description"] as? String else {
        return
    }

    bestAttemptContent.body = description
    bestAttemptContent.userInfo["screen"] = "notification"
    bestAttemptContent.userInfo["item_id"] = itemId
}.resume()

Every early exit — no ID, no token, no tenant, a malformed URL, a network failure, unparsable JSON — falls back to contentHandler(bestAttemptContent) with the original, thin content rather than leaving the notification hanging. The extension either shows something better than the raw payload, or shows what it started with. It never shows nothing.

The #if DEBUG split on the App Group suite name matters more than it looks — without it, a debug build talking to a staging backend and a release build talking to production would both read from the same shared container, and a token meant for one environment could silently leak into a push destined for the other.

Why this is different from normal app work

Debugging this extension mostly meant NSLog statements read from a device’s console log, since there’s no debugger attached to a process the OS spins up on its own schedule for a push you can’t always trigger on demand. It’s a small amount of code — one class, one native bridge, one shared-storage utility — but it’s also the one part of the notification pipeline that runs completely outside the app’s normal lifecycle, on someone else’s clock, with no room for a mistake that leaves the content handler uncalled.