Route TestFlight Builds to Staging, App Store Builds to Production

Route TestFlight Builds to Staging, App Store Builds to Production
Quick answer: TestFlight tester groups only control who can install a build — they do not change what the binary talks to. Detect the environment in code (a TestFlight build carries a sandboxReceipt, an App Store build carries receipt) or, more reliably, bake the base URL in at build time with an xcconfig and a separate scheme. Always default to production and enforce access rules on the server.

Why this happens

Two different questions get mixed together: who can install this build and what this build is configured to talk to. Assigning a tester group in App Store Connect answers only the first one. The binary itself is byte-for-byte the same whether an internal tester installs it from TestFlight or a customer downloads it from the App Store.

That is why “put staging testers in a TestFlight group” does not work as an environment switch. The build you hand to testers is the build you later submit for review, so any staging URL compiled into it goes to production with it.

The second trap is #if DEBUG. TestFlight builds are Release builds, so DEBUG is not defined and that branch never runs. You need a signal that survives archiving.

How to fix it

1. Detect the receipt, not the tester group

Every App Store install has a receipt. Sandbox installs — TestFlight, local StoreKit testing, development-signed builds — get a receipt with a different file name. Checking that name works in a Release build and needs no entitlements.

import Foundation

enum BuildEnvironment {
    case production
    case staging

    static var current: BuildEnvironment {
        #if DEBUG
        return .staging
        #else
        return isSandboxReceipt ? .staging : .production
        #endif
    }

    private static var isSandboxReceipt: Bool {
        guard let receiptURL = Bundle.main.appStoreReceiptURL else {
            return false // no receipt: assume production
        }
        return receiptURL.lastPathComponent == "sandboxReceipt"
    }
}

Then read the base URL once at launch:

enum API {
    static let baseURL: URL = {
        switch BuildEnvironment.current {
        case .staging:    return URL(string: "https://api.example-staging.com")!
        case .production: return URL(string: "https://api.example.com")!
        }
    }()
}

Caveats worth knowing before you rely on this:

  • A missing receipt falls through to production, which is the safe default.
  • Local StoreKit testing configurations and Xcode-installed builds also look like sandbox, so a Release build run from Xcode will point at staging. That is usually what you want.
  • If your app is built with a non-Apple framework, the check still lives in native code — expose the single boolean to your JavaScript layer through a small native module rather than duplicating the logic.

If you already use StoreKit 2, AppTransaction reports the environment the app was acquired in (.xcode, .sandbox, .production), which is a cleaner signal than the receipt file name when you have it available.

2. Bake the URL in at build time (the reliable option)

Runtime detection is convenient but it is still one binary guessing. The sturdier approach is to make the environment a build setting, so the staging build physically cannot contain the production URL and vice versa.

Add a user-defined setting to an .xcconfig file:

// Config.xcconfig
API_BASE_URL = https:/$()/api.example.com

The $() is not decoration — // starts a comment in xcconfig files, so this breaks the URL apart without breaking the value.

Reference it from Info.plist:

<key>APIBaseURL</key>
<string>$(API_BASE_URL)</string>

And read it back:

enum Config {
    static var apiBaseURL: URL {
        guard let raw = Bundle.main.object(forInfoDictionaryKey: "APIBaseURL") as? String,
              let url = URL(string: raw) else {
            fatalError("APIBaseURL missing or malformed in Info.plist")
        }
        return url
    }
}

Now create a Staging build configuration alongside Debug and Release, point its xcconfig at the staging file, and give it its own scheme. Archive with the Staging scheme for testers and with the Release scheme for submission. Nothing is guessed at runtime, and a reviewer can inspect the shipped Info.plist and see exactly one host.

Do not put API keys or client secrets in Info.plist — it is readable from the IPA. Anything sensitive belongs behind server-side authorization.

3. If you allow overrides, keep them visible and enforce them server-side

A common middle ground is to ship production defaults and let testers override the host from a settings screen, persisted to local storage so it survives app upgrades:

struct EndpointOverride: Codable {
    var apiBaseURL: URL
    var issuerBaseURL: URL
    var clientID: String
}

Store it in UserDefaults (or the Keychain if it includes anything credential-like), and resolve the effective endpoint as override ?? Config.apiBaseURL.

Two rules make this safe:

  • The switcher must be visible. Hidden or undocumented features are a well-known source of App Review rejections. Either show the screen, or compile it out of the Release configuration entirely.
  • The server decides what a token can reach. A client that points at staging should still be rejected by production, and vice versa. Never let a URL override be the only thing standing between a user and production data.

4. Consider a separate bundle identifier for staging

The cleanest separation is a second bundle ID, for example com.example.app.staging, with its own App Store Connect app record and its own provisioning profile. Benefits: both apps install side by side on one device, staging builds can never be confused with production, and the production app record’s TestFlight page stays clean. The cost is a second app record to maintain and a second set of certificates.

How to prevent it

  • Default to production whenever detection is ambiguous or fails.
  • Never use #if DEBUG as your only environment switch — TestFlight builds are Release builds.
  • Archive and test the exact configuration you intend to submit, not just the debug build on your simulator.
  • Log the resolved environment name at launch (never the full URL with credentials) so testers can confirm which backend they hit.
  • Keep environment-specific secrets out of the app bundle; put authorization on the server.
  • Keep the staging and production xcconfig files in version control so the difference between builds is reviewable.

FAQ

Does a TestFlight build use the sandbox or production environment?

For StoreKit, TestFlight builds run against the sandbox environment, which is why test purchases there do not charge real money. For your own backend, nothing is decided for you — the build talks to whatever URL it was compiled or configured with.

Can I use #if DEBUG to tell TestFlight from App Store builds?

No. TestFlight builds are archived with the Release configuration, so DEBUG is not defined. Use the receipt check or a build-time setting instead.

Will App Review reject an app that can switch API environments?

Not automatically, but a hidden or undocumented switcher is risky. If the setting is user-visible and clearly labelled, or compiled out of the submitted configuration, you avoid the problem entirely.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.