Short answer: register a small set of test phone numbers that map to fixed verification codes in your auth provider, draw those numbers from a range your national regulator has never allocated to any operator, and hand one of them to App Review. Your auth service short-circuits verification for registered test numbers, so no message is ever generated, no SMS provider is billed, and the reviewer signs in on the first attempt from anywhere in the world.
That’s the mechanism in a sentence. The rest of this post covers why the problem exists, what each part of the fix actually does, how to pick numbers that can’t cause harm, and the second failure mode that will reject your submission again after login already works. That second one costs teams weeks, because it looks nothing like an authentication problem and every instinct points the wrong way.
Why phone OTP quietly breaks App Review
Phone number login is the default across a large part of the world, for good reasons. It removes password management from the user. It removes password resets, credential stuffing exposure, and the support burden that comes with both. In markets where email is a secondary or workplace-only medium, a phone number is often the only identifier a user reliably has and reliably remembers. For consumer apps in South Asia, Southeast Asia, Africa, the Middle East, and Latin America, asking for an email address is the friction, not the convenience.
So the design is sound. The problem isn’t the choice of identifier.
The dependency nobody designs around
Phone OTP has a property email auth doesn’t: it depends on physical possession of a SIM issued by a carrier in a specific country.
Email is borderless. Any reviewer, anywhere, can receive a verification link at an address you provide. A phone number isn’t borderless. A code sent to an Indian, Nigerian, Brazilian, or Sri Lankan number terminates on a handset connected to a carrier in that country. No mechanism exists by which a person sitting in an App Review facility in California can read it.
This is invisible during development, because everyone building the app holds a local SIM. It’s invisible during internal testing, for the same reason. It’s invisible during beta, because your testers are your users and they’re in your market. It becomes visible for the first time at the moment it’s most expensive: when your submission reaches a reviewer.
What the rejection looks like
The reviewer opens your app and sees a phone number field. They may enter a number of their own, which either gets rejected by your client-side validation because it doesn’t match your country’s format, or gets accepted and then silently fails because your SMS provider can’t deliver to it. They wait. Nothing arrives. They have no path forward.
What you get back is rarely an explicit statement that login failed. It’s usually a Guideline 2.1 rejection, which covers App Completeness and reads as a general assertion that the app couldn’t be fully reviewed. If your app is subscription-gated, it frequently arrives in the more specific form of Guideline 2.1(b):
We have started the review of the app, but we are not able to continue because we cannot locate the In-App Purchases within the app at this time.
Read that sentence carefully, because its phrasing causes an enormous amount of wasted effort. It says the reviewer could not locate your in-app purchases. It does not say your in-app purchases are misconfigured, and it does not say your paywall is broken. Teams read it as a StoreKit problem and spend days auditing product identifiers, subscription groups, pricing tiers, and whichever subscription management platform they use. The actual situation is that a person couldn’t get past your login screen and therefore never saw the screen your purchases live on.
Everything behind your front door is invisible if the reviewer can’t open the front door. The rejection describes what they could see, not what is wrong.
Why review notes don’t save you
The instinct is to explain the situation in the review notes. This doesn’t work, and understanding why saves a submission cycle.
App Review operates at volume, under time pressure, against a queue. A reviewer will follow explicit numbered steps. What a reviewer cannot do is acquire a foreign SIM, arrange for someone in your country to receive a code and relay it, or wait on a support channel while you coordinate. Any instruction whose completion depends on a human outside the review facility is an instruction that won’t be completed.
The login has to work for a person holding no SIM from your market. That’s the requirement. Everything below is how to satisfy it.
The three layers of a phone OTP system
Before the fix, it’s worth being precise about which component you’re modifying, because the fix lives in exactly one of them and most of the confusion comes from conflating them.
The identity layer generates the verification code, stores it securely, sets its expiry, enforces how often a code can be requested for a given number, limits how many times a wrong code can be submitted, compares the submitted code against what it stored, and issues a session token on a match. This is your auth service.
The delivery layer takes six digits and a phone number and causes those digits to appear on that handset. This is your SMS provider.
The client layer collects the phone number, collects the code, and holds the resulting session. This is your app.
Most teams think of identity and delivery as one thing, because most auth services bundle them. They aren’t one thing, and separating them in your mental model is what makes both the App Review fix and the regional provider problem tractable.
flowchart LR
subgraph identity["Identity layer"]
I1[Generate code]
I2[Store it hashed]
I3[Set expiry]
I4[Throttle requests]
I5[Limit attempts]
I6[Issue session]
end
subgraph delivery["Delivery layer"]
D1[Put six digits on a handset]
end
subgraph client["Client layer"]
C1[Collect number]
C2[Collect code]
C3[Hold session]
end
C1 --> I1
I1 --> I2 --> I3 --> I4
I4 --> D1
D1 --> C2
C2 --> I5 --> I6 --> C3
Notice where the country dependency sits. Entirely in the delivery layer. The identity layer has no opinion about geography at all, because it’s comparing a submitted string against a stored hash. That’s why the fix doesn’t require weakening your authentication. It requires arranging for a specific, small set of numbers to skip the delivery layer.
Test numbers with fixed codes
What a test number does
Essentially every hosted auth provider supports registering a map of phone numbers to fixed verification codes. Supabase calls the setting test OTPs. Firebase calls them fictional phone numbers. The naming differs, the behaviour is the same.
When a number is in that map, the auth service changes what it does at the request step. Instead of generating a random code and dispatching it for delivery, it treats the configured code as the expected value and skips delivery. No message is generated. No SMS provider is contacted. Nothing is billed. The verification step then behaves exactly as it always does, comparing what the user submitted against what it expects and issuing a session on a match.
Four properties follow, and each one matters.
It works from anywhere. No carrier is involved, so no geography is involved. A reviewer in California and a developer in Colombo have identical experiences.
It works when your SMS provider is down. Delivery is skipped, so a provider outage or a broken integration doesn’t affect these numbers. That makes them useful for CI whether or not you ever submit to a store.
It costs nothing. No message is dispatched, so no message is billed. You can run the login flow ten thousand times in automated tests without a line item.
It exercises your real authentication path. This is the property that separates a test number from a debug bypass in your client code. The session that results is a genuine session, issued by the same code path, carrying the same claims, subject to the same expiry and refresh behaviour. You aren’t testing a special case. You’re testing the real thing with one input substituted.
That last point deserves emphasis, because the alternative is so tempting. The instinct, when a reviewer can’t receive an SMS, is to hardcode a bypass in the client: if the code is 111111, skip verification and proceed. This is catastrophic. It ships to every user. It’s trivially discoverable by anyone who decompiles your bundle or watches your network traffic. It grants access to every account in your system, not just test ones. We’ve written before about what happens when shortcuts like this reach production, and this is a textbook instance. A registered test number, by contrast, is a server-side config that grants access to exactly the accounts you listed and leaves the verification path intact for everyone else.
How to configure them
Through config as code, if your provider supports it:
[auth.sms.test_otp]
15551234567 = "111111"
Or through a management API, which is better for anything past a prototype because it’s reproducible, reviewable, and can live in version control next to your migrations instead of in one person’s browser history:
curl -X PATCH "https://api.<provider>.com/v1/projects/$PROJECT_REF/config/auth" \
-H "Authorization: Bearer $MANAGEMENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sms_test_otp": "15551234567=111111,15551234568=111111",
"sms_test_otp_valid_until": "2027-01-01T00:00:00Z"
}'
Four properties to understand before you rely on this
Matching is exact, with no pattern support. Every implementation we’ve examined matches the complete phone number. No wildcard, no prefix matching, no regular expression, no range syntax. If you want ten test numbers, you list ten test numbers.
This matters because the natural instinct, on learning that test numbers exist, is to ask whether an entire range can be whitelisted with one rule. It can’t. And the workaround that looks attractive, a server endpoint that mints a session for any number matching a pattern, is not a test fixture. It’s a permanent authentication backdoor with a shared secret, protecting every account whose number falls in that range. Your database almost certainly derives tenancy and ownership from the verified phone number, so such an endpoint hands complete access to any account in the range to anyone holding the secret. Don’t build it. List the numbers.
An expiry date is usually mandatory. In Supabase’s implementation, setting test numbers without also setting a validity end date is rejected outright. That’s a sensible constraint, because it forces the credential to have a lifetime instead of existing forever by default.
It also creates an operational hazard that’s easy to miss. When that date passes, your test numbers stop working, silently, with no deployment and no code change. If your next submission lands after that date, you’ll be debugging a login failure with no proximate cause in anything you did. Put the expiry date in your calendar with a reminder ahead of it, and pick a date comfortably beyond your expected submission cadence.
Config changes are not always instant. After you write the config, the running auth service may take a minute or two to observe it. During that window, a newly registered test number keeps behaving as an ordinary number: a random code is generated and dispatched for real delivery.
The debugging experience this produces is genuinely confusing. You add a number, test it immediately, the fixed code is rejected, and you conclude the config didn’t save. Meanwhile it saved and simply isn’t live yet. Wait, retry, and only then investigate.
These are credentials, and they’re permanent. A number mapped to a fixed code is a username and password that never rotates, sits in plain text in a config field, and appears in whatever documents you share it through, including your App Review submission and any internal ticket that references it.
Handle it accordingly. Keep the list short. Point those numbers at accounts scoped to demo data. Never point one at an account with production admin access, elevated permissions, or visibility into other tenants’ data. Assume every value you put in that map will eventually be seen by someone outside your team, because eventually it will be.
Choosing numbers that can never reach a real person
This is the refinement that turns a workable hack into something you can leave in production indefinitely, and it’s the part we haven’t seen documented anywhere.
Why the choice of number matters
Say you pick a plausible-looking number for your test account. Something that follows your country’s mobile format and looks unremarkable. Three problems follow.
A real message can escape to a real stranger. Recall the propagation delay above. During the window between writing the config and the service observing it, that number is treated as ordinary, which means a code is generated and dispatched. If the number belongs to somebody, they receive an unsolicited verification code from an app they’ve never used. The same happens for any request made before you added the config at all, including the one a developer makes while setting this up.
The number may later be allocated to an actual subscriber. Numbering plans are living documents. A number that’s unassigned today can be issued to a customer in two years. At that moment, a real person is holding the phone number that corresponds to a permanent, fixed-code login to your demo account. They won’t know this. You won’t know this. The exposure simply comes into existence.
Your SMS provider bills you for undeliverable attempts. Many providers charge on submission rather than on delivery confirmation. Messages dispatched to numbers that can’t exist still cost money.
The method
Choose your test numbers from a range that cannot be allocated to a subscriber. Every national numbering plan contains reservations and gaps, and the authority that publishes the plan also publishes which ranges are set aside.
Two ranges that are explicitly reserved for fictional use:
| Region | Range reserved for fictional or test use |
|---|---|
| North America, NANP | 555-0100 through 555-0199 |
| United Kingdom, Ofcom | 07700 900000 through 07700 900999, mobile format, reserved for drama and fiction |
Where your country publishes no explicitly reserved range, the general method works everywhere.
Step one. Get your national regulator’s published numbering plan. TRAI in India, Ofcom in the United Kingdom, NANPA for North America, and an equivalent authority in every other jurisdiction. These documents are public and list which prefixes are assigned to which operators.
Step two. Find a prefix inside the mobile numbering range that’s currently unallocated. You want a prefix that produces numbers of the correct total length, so your client-side validation accepts them as well-formed, while being assigned to no operator, so the network has nowhere to route them.
Step three. Take a small number of specific numbers from that prefix and register those in your test map.
The result has exactly the properties you want. To your app’s validation logic, these numbers are indistinguishable from real ones: correct length, correct format, no special-casing anywhere in your client. To the telephone network they’re unroutable, so a message dispatched to one of them terminates nowhere. And to an attacker who somehow learns both the number and the fixed code, there’s no real human on the other end whose account could be compromised, because no such account can exist.
The maintenance obligation this creates
Numbering plans change, so this needs periodic re-verification. A prefix that’s unallocated today may be assigned to an operator at some future point, and on that day your permanently valid test credential becomes a credential attached to a number a real person can be issued. Re-check the allocation status whenever you renew the expiry date on your test numbers. Where your country publishes an explicitly reserved fictional range, prefer it over a merely unused one: a reserved range carries a commitment not to allocate it, an unused one carries only the observation that it hasn’t been allocated yet.
The trap that rejects you a second time
You’ve done everything above. The reviewer can now log in. You resubmit, confident. You’re rejected again, with the same message about being unable to locate the in-app purchases.
This is where subscription apps lose a week or more. The failure isn’t in your purchase configuration, your paywall, or your login. It’s in the state of the account you handed over.
The mechanism
If the demo account you provided already has an active subscription entitlement, your paywall doesn’t render. Your routing logic is working exactly as designed: a user with an entitlement is a paying customer, and paying customers go to the product, not to a sales screen.
The reviewer signs in, lands in your app, and looks for the in-app purchases. There’s no purchase screen anywhere in their path, because your app correctly concluded that this user has already purchased. They report precisely what they observed, which is that the in-app purchases could not be located.
Every part of your system behaved correctly. The rejection is still entirely valid.
How an account ends up entitled without you noticing
Two mechanisms, and the second is considerably less obvious.
Residual state from your own testing. The account you’ve been using for months carries an entitlement from a sandbox purchase made during development, a promotional grant issued while testing the redemption flow, or a subscription that simply never expired. This account looks perfectly healthy to you, because you’ve been using it to test the paid experience, which is exactly what it’s good for and exactly what makes it useless as a review account.
A subtle variant shows up when your entitlement is stored server-side against a user rather than against a platform transaction. In that architecture, a purchase made in one store can unlock the app on a different platform. A test purchase made on Android will entitle the same user on iOS, because the server only knows this user is subscribed. If your team does most of its testing on one platform and submits on the other, you can hand Apple an account entitled entirely by a Google Play transaction and never once suspect it.
The sandbox subscription follows the reviewer, not the account. This is the mechanism that produces a genuine review loop, where you’re rejected repeatedly while doing everything you were told to do.
A sandbox purchase binds to the reviewer’s sandbox Apple ID, not to your app’s user record. So if a reviewer completes a purchase on demo account A, and you then provide a fresh demo account B, the same sandbox Apple ID restores the same subscription onto account B. Account B was clean when you created it. It’s entitled the moment the reviewer signs in.
You can generate ten fresh accounts and be rejected ten times, with each account verifiably unsubscribed at the moment you hand it over. Nothing you can observe from your own side explains it. This is known sandbox behaviour and reviewers will act on a clear description of it, but only if you write that description.
sequenceDiagram
autonumber
participant R as Reviewer
participant A as Your app
participant S as Your entitlement server
participant K as Sandbox Apple ID
Note over R,K: First submission
R->>A: Sign in as demo account A
A->>S: Query entitlement
S-->>A: Not subscribed
A-->>R: Paywall shown
R->>K: Completes sandbox purchase
K-->>S: Webhook writes subscription for account A
Note over K: Subscription binds to the<br/>reviewer Apple ID, not account A
Note over R,K: Second submission, fresh account B
R->>A: Sign in as demo account B
A->>S: Query entitlement
K-->>S: Sandbox restores prior purchase
S-->>A: Subscribed
A-->>R: Main app, no paywall anywhere
A-->>R: Rejection: cannot locate in-app purchases
Diagnosing which cause applies
When a rejection of this shape arrives, work through the possibilities in order instead of guessing, because three distinct root causes produce an identical symptom.
flowchart TD
A["Rejection: cannot locate<br/>the In-App Purchases"] --> B{"Can the reviewer sign in<br/>with no local SIM?"}
B -- No --> C["Root cause: no usable login.<br/>Register a test number<br/>with a fixed code"]
B -- Yes --> D{"Does your entitlement API report<br/>the demo account as subscribed?"}
D -- Yes --> E{"Did a reviewer previously<br/>purchase in sandbox?"}
E -- Yes --> F["Root cause: sandbox Apple ID<br/>carry-over. Explain it in the reply<br/>and supply a retired-account note"]
E -- No --> G["Root cause: residual entitlement<br/>from your own testing.<br/>Clear it or use a fresh account"]
D -- No --> H{"Are the products attached<br/>to the submitted build?"}
H -- No --> I["Root cause: unattached products.<br/>Products are unavailable in sandbox<br/>so the paywall renders empty"]
H -- Yes --> J{"Can the account's role<br/>actually make a purchase?"}
J -- No --> K["Root cause: account lacks<br/>purchase authority. Button renders<br/>disabled and reads as broken"]
J -- Yes --> L["Investigate StoreKit and<br/>store agreement status"]
Defences
Assert the state rather than assuming it. Before replying to App Review, query your entitlement API as that exact user and confirm it reports the account as not subscribed. Make the same call your app makes, with a real session for that account. Don’t infer the answer from a database row, because your app may derive entitlement through joins, caching, or a third-party service the row doesn’t reflect. Don’t infer it from memory of how you configured the account, because the whole point of this failure mode is that accounts change state without your involvement.
Retire one account per submission. Once a reviewer has completed a sandbox purchase against an account, that account is spent. Keep two or three pre-provisioned spares so swapping is a one-line change to your reply rather than a provisioning exercise under time pressure.
Reset the entitlement between submissions where a webhook wrote a record on your side. A single delete statement scoped to that account, run as part of your pre-submission checklist, removes the most common cause entirely.
Describe the sandbox behaviour explicitly when it applies. If you have reason to believe the reviewer’s own Apple ID is carrying a subscription from a previous review, say so plainly in your reply. Describe the mechanism, state that the account you supplied has no subscription on your side, and note that you can verify this on request. Reviewers act on clear technical explanations. They can’t act on information you didn’t provide.
Make your purchase screen reachable even for subscribed users. This is the structural fix rather than the tactical one, and it’s worth doing whether or not App Review ever comes up.
If the only route to your subscription options is a paywall that entitled users are routed past, then by construction a subscribed reviewer can’t find your in-app purchases. No amount of careful account provisioning changes that, it only avoids triggering it. Adding a subscription or plan management entry in your settings area means the purchase options are always reachable. That resolves this class of rejection permanently, gives existing subscribers a way to see and manage what they’re paying for, and gives you a place to surface upgrade paths. Better product design that happens to also be a compliance fix.
The unrelated cause with identical symptoms
One more possibility deserves separate mention, because it presents identically and has nothing to do with accounts.
On a first submission, in-app purchase products must be submitted together with the binary. Products that exist in App Store Connect but aren’t attached to the version under review are unavailable in the sandbox. Your app queries for its products, gets an empty result, and a well-built paywall degrades gracefully to a message about subscriptions being unavailable. A poorly built one renders an empty screen or an error.
Either way the reviewer reports that the in-app purchases couldn’t be located, and this time they’re right in a completely different way. Check the attachment state before assuming the account is at fault.
A pre-submission verification protocol
Run this against the exact account you’re about to hand over, not against an account that resembles it.
- The test number signs in with its fixed code, exercised from a device with no SIM from your market, ideally a simulator with no cellular capability at all
- The account resolves to a role that’s permitted to purchase. An account that’s a staff member, a sub-user, or any role your app forbids from buying will render your purchase button disabled, which reads to a reviewer as a broken in-app purchase and produces the same rejection
- Your entitlement API, queried with a real session for that account, reports it as not subscribed
- The purchase screen renders with every product visible, correctly priced, and the primary action enabled
- The in-app purchase products are attached to the build being submitted
- The Paid Applications Agreement is accepted, since paid in-app purchases don’t function until it is
- The account contains enough sample data that the app doesn’t appear empty or non-functional after a purchase completes, since an empty app invites a separate rejection for incompleteness
- The expiry date on your test number config falls comfortably beyond your expected submission timeline
- The steps in your review notes match what that specific account actually does. If your notes say subscription options appear immediately, but that account has no profile configured and hits an onboarding form first, you’ve introduced a discrepancy between your instructions and the reviewer’s experience, which invites a clarification cycle you don’t need
Then run the entire flow yourself, on a clean device or simulator, as though you were the reviewer. Not the API calls. Not the individual components. The actual app, from launch to purchase screen.
This last step isn’t redundant with the checks above. Verifying each component individually establishes that each component works. It doesn’t establish that the sequence works, and the sequence is what the reviewer experiences. Client-side state, hydration timing, navigation guards, and caching all live in the gap between a correct API and a correct app.
What to write back to App Review
Reviewers work through a queue under time constraints. Give them a numbered path and nothing that competes with it.
The In-App Purchases were not reachable because the demo account provided with the previous submission already held an active subscription entitlement, so the app opened past the subscription screen. A new account with no subscription is provided below.
- Launch the app
- Enter mobile number:
<test number>- Tap Request Code
- Enter verification code:
<fixed code>- The subscription screen appears immediately, showing three auto-renewable subscriptions:
<product identifiers>- Select a plan and tap the purchase button at the bottom of the screen
This is a registered test number. No SMS is sent and the code above is always valid. The In-App Purchases are not restricted by storefront, region, or device configuration, and are offered to every account that does not already hold an active subscription.
Two points of technique.
Provide exactly one account. The instinct is to supply several so the reviewer has alternatives if one fails. That backfires. If you list three accounts and two behave differently, for instance because two have no shop configured and route to an onboarding form, you’ve created ambiguity where you were trying to create redundancy. Keep the spares internal and swap them in on the next submission if needed.
State the absence of restrictions explicitly. Apple’s rejection message specifically asks whether access is restricted by storefront or device configuration. Answering that directly, before it’s asked a second time, removes one round trip.
Takeaway
Phone OTP apps don’t fail App Review for subtle reasons. They fail because a person in a different country can’t receive your text message, and because nobody on the team noticed that this was a requirement until a reviewer did.
Three measures resolve it permanently.
Separate delivery from identity. Your auth service doesn’t care about geography, only message delivery does. Recognising that separation is what makes the other two measures possible, and it’s the same separation that lets you use a regional SMS provider your platform doesn’t natively support, covered in Supabase Auth with an unsupported SMS provider.
Register test numbers with fixed codes so App Review, your automated tests, and your new engineers can all authenticate without a SIM from your market, using your real authentication path rather than a bypass.
Draw those numbers from a range that can’t be allocated so a stray message reaches nobody, the credential can never collide with a real subscriber, and the arrangement stays safe to leave in place indefinitely.
Then verify the one thing everybody forgets: that the account you’re about to hand a reviewer is not already subscribed. Check it by making the call your app makes, for that specific account, immediately before you reply.