CORE JSC

International Technology Partnership

Mobile Development

Fixing React Native iOS Push Notifications That Work in Xcode Debug but Silently Fail in TestFlight (APNs Environment Mismatch)

Push notifications arrive reliably when the app is run straight from Xcode on a connected device, with no backend changes between builds. The moment the same app is installed through TestFlight instead, notifications stop arriving entirely — no error on the client, no error from APNs, nothing in the app's own logs, because the failure happens entirely on Apple's side between two push environments that never talk to each other.

Core JSC Team·November 7, 2026
React NativeiOSPush NotificationsAPNsMobile Development

The Problem

A React Native app registers for push notifications and receives them correctly every time it's launched directly from Xcode onto a physical device — the device token is generated, sent to the backend, and notifications arrive within seconds of being triggered. The exact same build (same bundle identifier, same push payload, same backend code) is then archived and distributed through TestFlight, and from that point on, notifications simply never arrive. There's no crash, no visible error on the device, and the backend's send call to APNs still reports success, which makes the failure look like it has no cause at all.

Why It Happens

Apple runs two entirely separate APNs environments, and a device token from one means nothing to the other

Apple Push Notification service operates two independent environments — sandbox (api.sandbox.push.apple.com) for development builds and production (api.push.apple.com) for everything distributed outside Xcode, including TestFlight and the App Store. A device token issued while running under one environment is not valid for sending through the other; there's no translation or fallback between them, so a server configured to talk to only one environment will reliably fail for devices registered under the other, with APNs giving no indication that an environment mismatch is the cause.

Which environment a build registers under is decided by code signing, not by the Xcode scheme's Debug/Release setting

It's easy to assume the push environment follows the same Debug/Release toggle used for everything else in an Xcode scheme, but it doesn't — it's determined by the aps-environment value baked into the app's entitlements at signing time, which in turn comes from the provisioning profile used. A development provisioning profile always signs with aps-environment: development; any distribution-type profile — ad hoc, App Store, or the one TestFlight uses — always signs with aps-environment: production, regardless of the scheme's build configuration.

The backend or push-sending service is often hardcoded to a single APNs host from early development

During early development, it's common to point the backend's push integration at the sandbox APNs host exclusively, since every build at that stage is a development build. That hardcoded sandbox endpoint then quietly becomes wrong the moment the first TestFlight build goes out, because TestFlight device tokens need the production host — and since APNs rejects a mismatched token-to-environment combination without surfacing a client-visible error, this misconfiguration can go unnoticed through multiple TestFlight releases.

The Fix

1. Confirm the actual signed environment in the shipped build, rather than assuming it from the Xcode scheme

unzip -p YourApp.ipa "Payload/YourApp.app/archived-expanded-entitlements.xcent" | grep -A1 aps-environment
# or, on an installed build:
codesign -d --entitlements :- YourApp.app | grep -A1 aps-environment

This reads the entitlement that was actually embedded at signing time, which is the only reliable way to know whether a given build will register for sandbox or production push — checking the Xcode scheme or build configuration name proves nothing about this value.

2. Use a single APNs authentication key (.p8) instead of per-environment certificates where possible

APNs Auth Key (.p8)  -> works against BOTH sandbox and production hosts
APNs Certificate (.p12) -> separate cert required per environment

Apple's token-based authentication key works identically against both the sandbox and production APNs hosts, removing an entire class of environment-mismatch bugs that come from only ever generating or renewing a certificate for one environment. Migrating off the older certificate-based approach, if the backend still uses it, removes most of the risk this class of bug represents going forward.

3. Have the backend route each token to the correct host based on which environment it was issued under

// Store the issuing environment alongside the token at registration time,
// instead of assuming every token in the database is production.
const apnsHost = tokenRecord.environment === "sandbox"
  ? "api.sandbox.push.apple.com"
  : "api.push.apple.com";

Rather than hardcoding a single host, the registration endpoint should record which environment each incoming token was generated under (inferred from which build type is calling it, or passed explicitly by the client) so sends can be routed correctly per device — this also makes it possible to support development and TestFlight builds side by side without either one silently failing.

4. Log the actual APNs HTTP/2 response status and reason per send, instead of treating a 200 as the only outcome worth checking

:status: 400
apns-id: 
{"reason":"BadDeviceToken"}

A BadDeviceToken response is exactly what APNs returns when a token is presented to the wrong environment's host, and it's returned per-notification over the same HTTP/2 connection used to send — a backend that only checks whether the overall request succeeded, without inspecting each individual response, will miss this signal entirely and report false "success" for notifications that never had a chance of being delivered.

Why This Works

The underlying issue is never that push notifications are broken in the app — the client-side registration, token generation, and payload handling are typically identical between the Xcode and TestFlight builds. What changes is purely which of Apple's two APNs environments the build is authorized to talk to, decided entirely by the provisioning profile at signing time. Verifying the real entitlement, using an environment-agnostic auth key, routing by the token's actual environment, and reading APNs's own per-notification response code together close every point where that mismatch could otherwise go undetected.

Conclusion

Push notifications that work perfectly from Xcode but go silent in TestFlight are almost never a client bug — they're a sandbox token being sent to the production APNs host, or vice versa, with no error surfaced unless someone goes looking for it. Checking the signed aps-environment entitlement directly, preferring a .p8 auth key, environment-aware routing on the backend, and reading the real per-notification APNs response turns this from an untraceable mystery into a one-line fix.