Push Notifications in iOS 26: The Complete SwiftUI Guide to APNs, Rich Media, and Service Extensions
A field-tested walkthrough of iOS 26 push notifications in SwiftUI: APNs authorization, JWT-signed token auth, Notification Service Extensions for rich media, silent background pushes, and the misconfigurations that quietly break delivery.
Push notifications in iOS 26 are delivered to your SwiftUI app through Apple Push Notification service (APNs) and rendered by the UserNotifications framework, which now supports rich media attachments, communication notifications, time-sensitive interruption levels, and Live Activity updates over a single token-authenticated HTTP/2 connection. This guide walks through the full pipeline: requesting authorization in SwiftUI, signing a JWT for your provider server, building a Notification Service Extension that decrypts payloads on device, and debugging the silent failures that plague most production apps.
Honestly, I've shipped push for three apps now and the same five mistakes show up every time. So instead of just dumping the API surface, I've tried to call out the gotchas I hit in production (the ones that cost me a weekend of debugging) right alongside the code.
iOS 26 requires explicit UNAuthorizationOptions consent for alerts, sounds, badges, and the new .timeSensitive and .criticalAlert interruption levels before APNs will deliver any user-visible payload.
Token-based APNs authentication using a .p8 key and an ES256-signed JWT is the modern replacement for certificate-based auth, and it's required for HTTP/2 push delivery.
A Notification Service Extension can mutate the payload before display, enabling end-to-end encrypted messages, dynamic localization, and rich media (images, GIFs, audio) attached via UNNotificationAttachment.
Silent background pushes use the content-available: 1 key with no alert field, are throttled by iOS 26's energy heuristics, and require remote-notification in UIBackgroundModes.
The new push-type: liveactivity header lets your server update Live Activities and Dynamic Island content without requiring the host app to be running.
Most "notification not arriving" bugs are misconfigured entitlements, a stale device token after app reinstall, or a missing aps-environment value rather than a server problem.
How push notifications work in iOS 26
A push notification round-trip involves five distinct actors: your app, the user's device, Apple Push Notification service, your provider server, and (optionally) a Notification Service Extension. When the user installs your app and grants permission, the system asks APNs for a unique device token, a 32-byte opaque identifier scoped to that device, your bundle ID, and the current APNs environment (sandbox or production). You forward this token to your backend, which stores it next to the user record. Later, when your server wants to push something, it sends an HTTP/2 POST to api.push.apple.com carrying a JSON payload and an authorization JWT, addressed to that specific token.
APNs forwards the message over its persistent connection to the device, even when your app is suspended or terminated, and the system either displays the notification directly or wakes a Notification Service Extension to modify the payload first. iOS 26 has tightened the energy and privacy budget for this flow. Silent background pushes are coalesced more aggressively, communication notifications now require the INSendMessageIntent donation to render as a person avatar, and the new .timeSensitive interruption level can break through Focus modes when used appropriately. Misuse triggers a system warning and can suppress your future notifications entirely.
Request authorization in SwiftUI
Before APNs will deliver any user-visible notification, you must ask the user for permission via UNUserNotificationCenter. In SwiftUI, the cleanest place is an @MainActor service object you inject into the environment and call from a button tap or onboarding step, not from onAppear, which iOS 26's Notifications Human Interface Guidelines explicitly discourage. Combine all the options you might ever need into one request; you cannot upgrade .alert to .criticalAlert later without a fresh prompt.
import SwiftUI
import UserNotifications
@MainActor
@Observable
final class PushPermissions {
enum State { case undetermined, granted, denied, provisional }
private(set) var state: State = .undetermined
func refresh() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
state = switch settings.authorizationStatus {
case .authorized: .granted
case .denied: .denied
case .provisional: .provisional
default: .undetermined
}
}
func request() async throws {
let options: UNAuthorizationOptions =
[.alert, .sound, .badge, .providesAppNotificationSettings, .timeSensitive]
let granted = try await UNUserNotificationCenter
.current()
.requestAuthorization(options: options)
state = granted ? .granted : .denied
if granted {
await UIApplication.shared.registerForRemoteNotifications()
}
}
}
struct OnboardingView: View {
@Environment(PushPermissions.self) private var perms
var body: some View {
Button("Enable notifications") {
Task { try? await perms.request() }
}
.task { await perms.refresh() }
}
}
Use .provisional if you want to deliver silently to the Notification Center first and let the user upgrade you to full alerts after they see your content. The providesAppNotificationSettings flag adds a "Notification Settings" deep link inside iOS Settings (wire it up by implementing userNotificationCenter(_:openSettingsFor:) in your notification delegate).
Register for a device token and send it to your server
Calling registerForRemoteNotifications() kicks off a network round-trip to APNs and ultimately calls back into your UIApplicationDelegate with either a token or an error. SwiftUI apps without an explicit UIApplicationDelegate need to provide one via @UIApplicationDelegateAdaptor, because the lifecycle modifiers in App don't expose these callbacks.
import SwiftUI
import UIKit
@main
struct ShipFastApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
var body: some Scene {
WindowGroup { RootView() }
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ app: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken data: Data
) {
let token = data.map { String(format: "%02x", $0) }.joined()
Task { await DeviceTokenAPI.upload(token) }
}
func application(
_ app: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
// Most common: missing aps-environment entitlement,
// simulator without an APNs-capable host, or no internet.
print("APNs registration failed:", error)
}
}
Device tokens are not stable. They rotate when the user restores from backup, reinstalls the app, migrates to a new device, or after extended uninstall. Treat the value passed to didRegisterForRemoteNotificationsWithDeviceToken as the source of truth and overwrite the previous one on your backend each launch. I hit this exact bug shipping a beta last year, a stale token on the server is the single most common cause of "I'm not getting notifications anymore" support tickets, and it's also the easiest one to fix.
APNs payload anatomy and headers
Every push is a JSON document under 4 KB (4096 bytes including JSON envelope, 5 KB for Voice over IP and Live Activity types) sent to https://api.push.apple.com/3/device/<token> with a small set of HTTP/2 headers that control routing, priority, and expiration. The aps dictionary is reserved for system fields; everything else is custom data your app can read in the notification handler.
UNIX timestamp; 0 = drop if not delivered immediately.
0 or now+3600
apns-collapse-id
Coalesces multiple pushes into one notification.
chat-thread-91
authorization
Bearer JWT for token-based auth.
bearer eyJhbGciOi…
A typical alert payload looks like this:
{
"aps": {
"alert": {
"title": "New message from Alex",
"subtitle": "Project Falcon",
"body": "Can you ship the build before standup?"
},
"sound": "default",
"badge": 3,
"interruption-level": "time-sensitive",
"mutable-content": 1,
"thread-id": "project-falcon",
"relevance-score": 0.9
},
"messageId": "01H8K9M2NQ",
"encryptedBody": "AES256-GCM-..."
}
Set mutable-content: 1 whenever you want a Notification Service Extension to run. Without it, the extension is skipped even if installed. The relevance-score (0.0–1.0) tells iOS 26's Notification Summary feature how to rank your push when the user has summaries enabled.
Sign a JWT for token-based APNs authentication
Apple deprecated certificate-based authentication in 2022; modern servers use a JSON Web Token signed with an ES256 private key (the .p8 file you download from Apple Developer → Keys). The same key works for sandbox and production and across all your apps in the team, which is a meaningful operational simplification over the old per-app certificates.
The token has three claims (iss with your 10-character Team ID, iat with an issued-at UNIX timestamp, and a header containing the Key ID) and must be refreshed at least every 60 minutes. APNs rejects tokens older than one hour. Here is a minimal signing routine in server-side Swift:
import Crypto
import Foundation
struct APNsToken {
let teamId: String
let keyId: String
let p8Key: P256.Signing.PrivateKey
func make() throws -> String {
let header = ["alg": "ES256", "kid": keyId]
let claims: [String: Any] = [
"iss": teamId,
"iat": Int(Date().timeIntervalSince1970)
]
let h = try base64URL(JSONSerialization.data(withJSONObject: header))
let c = try base64URL(JSONSerialization.data(withJSONObject: claims))
let signingInput = "\(h).\(c)"
let signature = try p8Key.signature(for: Data(signingInput.utf8))
return "\(signingInput).\(signature.rawRepresentation.base64URLEncoded())"
}
private func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Cache the JWT for 50–55 minutes; reusing it across thousands of pushes is encouraged and cuts your CPU cost dramatically. Send the same token with every request as Authorization: bearer <jwt>. For a production reference implementation see Apple's Sending Notification Requests to APNs documentation.
Build a Notification Service Extension for rich media
A Notification Service Extension is a separate target in your Xcode project that intercepts incoming pushes (when mutable-content: 1 is set) and gets up to 30 seconds of background CPU time to modify the payload before the system shows it. The two canonical use cases are decrypting an end-to-end encrypted message body and downloading a remote image to attach to the alert. Both are impossible in the main app because it might not be running.
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
var handler: ((UNNotificationContent) -> Void)?
var bestAttempt: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.handler = contentHandler
self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent
guard let attempt = bestAttempt else { return }
Task {
if let cipher = request.content.userInfo["encryptedBody"] as? String,
let plain = try? await E2EE.decrypt(cipher) {
attempt.body = plain
}
if let urlString = request.content.userInfo["imageURL"] as? String,
let url = URL(string: urlString),
let attachment = try? await downloadAttachment(url) {
attempt.attachments = [attachment]
}
contentHandler(attempt)
}
}
override func serviceExtensionTimeWillExpire() {
if let handler, let attempt = bestAttempt { handler(attempt) }
}
}
Always call contentHandler exactly once. Failure to do so within 30 seconds forces iOS to display the original payload and counts as a black mark against your delivery reliability. Image attachments must be saved to the extension's container directory; the system copies them out and renders them in the long-look notification UI.
Handle notifications while the app is in the foreground
By default, iOS suppresses your notification's UI when your app is in the foreground. You change that by setting a UNUserNotificationCenterDelegate and returning presentation options. In SwiftUI, register this delegate inside the same AppDelegate you created for token registration.
The didReceive response callback is also where you handle taps on notification action buttons that you previously registered as UNNotificationCategory objects. Pair this with the SwiftUI NavigationStack deep linking patterns we covered in a prior guide to drive the user directly to the relevant screen.
Silent background push and content-available
A silent push has no alert, sound, or badge field (only content-available: 1) and is intended to give your app a few seconds of background runtime to fetch fresh data without disturbing the user. The payload type must be set to apns-push-type: background and the priority to 5, otherwise APNs may reject the request. Your app must also declare remote-notification in UIBackgroundModes.
iOS 26 throttles silent pushes far more aggressively than visible ones. If the user rarely opens your app, the system may queue or drop them, and they don't arrive at all in Low Power Mode. For predictable refresh cadence pair this with the BGAppRefreshTask API covered in our SwiftUI background tasks guide, which uses both signals together. To update an active Live Activity from your server, use apns-push-type: liveactivity with the activity token. See our Live Activities and Dynamic Island guide for the full flow.
Why are my push notifications not working in iOS 26?
Roughly nine out of ten "my pushes don't arrive" reports trace back to the same short list of misconfigurations. Walk through them in order before suspecting APNs itself, which has 99.99% delivery reliability across Apple's published metrics.
Missing or wrong aps-environment entitlement. Open your target's Signing & Capabilities pane and confirm Push Notifications is added. The entitlement must read development in debug builds and production in App Store and TestFlight builds. Sending a development token to the production APNs endpoint silently fails.
Wrong APNs host. Development tokens go to api.sandbox.push.apple.com; production tokens to api.push.apple.com. There is no error response when you swap them, just no delivery.
Stale device token. Tokens rotate on reinstall, restore, and major iOS upgrades. Always overwrite the server-side value with the latest one passed to didRegisterForRemoteNotificationsWithDeviceToken on every launch.
User disabled notifications. Call notificationSettings() and surface a Settings deep link when authorizationStatus is .denied. iOS 26 also exposes per-category controls, so the user might have allowed your app overall but muted the specific thread-id.
Payload too large. The 4 KB limit includes the JSON envelope. APNs returns HTTP 413 (PayloadTooLarge) and the push is dropped.
Throttling. Silent pushes from apps the user hasn't opened in days are deprioritised. Low Power Mode and Focus modes also suppress non-time-sensitive alerts.
What is the difference between APNs and Firebase Cloud Messaging?
Apple Push Notification service is the only transport that can actually deliver a notification to an iOS device. There is no alternative. Firebase Cloud Messaging (FCM) is a Google-operated wrapper that takes your payload, translates it into APNs format, and forwards it on your behalf, so you only need a single API on Android and iOS. The trade-off is an extra hop, extra latency, and an extra third party that needs your APNs key.
Choose direct APNs when you want the lowest latency, strict data residency (Apple's servers only), and full control over headers like apns-collapse-id and apns-expiration. Choose FCM when cross-platform topic broadcasts, server-side analytics on delivery, or the convenience of one SDK outweigh the extra latency. Modern Swift backends using Vapor or a small SwiftNIO HTTP/2 client can speak APNs directly in under 200 lines of code, which is often less than the FCM integration would cost.
Frequently Asked Questions
How do I test push notifications in the iOS Simulator?
Create a .apns file containing a valid payload with a Simulator Target Bundle field set to your bundle ID, then drag it onto the simulator window. Alternatively, run xcrun simctl push <device-id> com.example.app payload.apns from Terminal. This works for visible alerts, but silent content-available pushes are unreliable in the simulator, so always validate them on a real device.
What is the maximum size of an APNs payload?
4096 bytes (4 KB) for standard alert, background, and silent pushes, including the JSON envelope and all custom keys. Voice over IP (VoIP) and Live Activity pushes are allowed 5120 bytes (5 KB). Exceeding the limit returns HTTP 413 PayloadTooLarge and the push is dropped without retry.
Can I send a push notification without a server?
Not for remote pushes. APNs only accepts HTTP/2 requests signed with a JWT, which requires keeping your .p8 private key off the client. You can, however, schedule local notifications entirely on-device with UNUserNotificationCenter.add(_:), which is suitable for reminders, alarms, and calendar-style alerts that don't depend on remote data.
How do I add a custom sound to a push notification?
Bundle the audio file (under 30 seconds, in .aiff, .wav, or .caf format) in your app's main bundle, then reference it in the payload as "sound": "chime.caf". For critical alerts that bypass silent mode, use "sound": {"critical": 1, "name": "alarm.caf", "volume": 1.0}. This requires the .criticalAlert authorization option and the Critical Alerts entitlement granted by Apple.
Do I need to ask permission for silent background pushes?
No. Silent pushes with only content-available: 1 don't require UNUserNotificationCenter authorization because they never show UI. They do require remote-notification in UIBackgroundModes and a valid device token from registerForRemoteNotifications(). Note that iOS 26 will still throttle delivery aggressively if the user never opens your app.
Tomasz is a Krakow-based iOS engineer with 11 years of Swift experience. He spent four years at Revolut on the Wealth team, where he rewrote the trading charts in SwiftUI and shaved 40% off cold-start time by lazy-loading the analytics SDK. Before Revolut he was at Allegro, Poland's largest e-commerce platform, on the Seller Center iOS team.
His specialty is iOS performance work: Instruments deep-dives, memory-graph debugging, and figuring out why your scroll view drops frames only on iPhone SE 2nd-gen. He has contributed patches to swift-syntax and writes a quarterly newsletter for iOS engineers that covers under-discussed APIs like BackgroundTasks and NSFileCoordinator.
Tomasz holds the iOS App Development with Swift certification from Apple and occasionally runs paid workshops on Swift concurrency for in-house engineering teams in Europe.
A hands-on walkthrough of the Xcode 26 SwiftUI preview crash on certain @Observable types, what the crash log really means, three workarounds that work today, and the bug report shape Apple triages fastest.
Why iOS 26's .glassEffect() modifier silently fails in real SwiftUI hierarchies, the four preconditions Apple's docs gloss over, and the exact GlassEffectContainer fix I used in production.
Apple shipped the Liquid Glass design language with iOS 26, and SwiftUI exposes most of it through a single new modifier: .glassEffect(_:in:). The API surface is tiny. The behavior is not.