Short answer: register a few 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 to App Review. Your auth service short-circuits verification for registered test numbers, so no message is generated, no provider is billed, and the reviewer signs in from anywhere on the first attempt.
Two parts of that are not obvious and cost teams the most time:
- Which range you draw from matters, because during config propagation a real code can escape to a real stranger, and an unassigned number today can be issued to a customer in two years. Details below, and we have not seen this documented anywhere else.
- Login working does not mean you pass. A sandbox subscription binds to the reviewer’s Apple ID, not your user record, so a freshly created demo account can arrive already entitled and your paywall correctly refuses to render. That produces a rejection loop that looks nothing like an auth problem.
Why it breaks
Phone OTP depends on physical possession of a SIM issued by a carrier in a specific country. Email is borderless; a phone number is not. A code sent to an Indian, Nigerian, Brazilian, or Sri Lankan number lands on a handset connected to a carrier there, and no one in an App Review facility in California can read it. This is invisible in development, internal testing, and beta, because everyone involved holds a local SIM. It becomes visible when your submission reaches a reviewer.
What comes back is rarely “login failed”. It is usually Guideline 2.1, App Completeness, or for subscription apps the more specific 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 carefully, because the phrasing causes enormous wasted effort. It says the reviewer could not locate your purchases. It does not say they are misconfigured. Teams read it as a StoreKit problem and spend days auditing product identifiers and subscription groups, when a person simply could not get past the login screen and never saw the screen your purchases live on.
Explaining this in review notes does not work. A reviewer will follow numbered steps. What they cannot do is acquire a foreign SIM or wait while someone in your country relays a code. Any instruction depending on a human outside the review facility will not be completed. The login has to work for a person holding no SIM from your market.
Test numbers with fixed codes
Essentially every hosted auth provider supports mapping phone numbers to fixed codes. Supabase calls them test OTPs, Firebase calls them fictional phone numbers. When a number is in that map, the service treats the configured code as expected and skips delivery entirely. Verification then behaves exactly as always, comparing submitted against expected and issuing a real session.
That last point is what separates this from a debug bypass in your client. The session is genuine, from the same code path, with the same claims and expiry. You are testing the real thing with one input substituted. The alternative is tempting and catastrophic: hardcoding “if the code is 111111, skip verification” ships to every user, is trivially found by anyone who decompiles your bundle, and grants access to every account in your system. We have written about what happens when shortcuts like this reach production.
[auth.sms.test_otp]
15551234567 = "111111"
Or through a management API, which is better past a prototype because it is reproducible and lives in version control next to your migrations:
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 things to know before relying on this.
Matching is exact. No wildcard, no prefix, no regex, no ranges. Ten test numbers means ten entries. The workaround that looks attractive, a server endpoint minting sessions for any number matching a pattern, is not a test fixture. It is a permanent authentication backdoor with a shared secret, and since your database almost certainly derives tenancy from the verified phone number, it hands complete account access to anyone holding that secret. List the numbers.
An expiry date is usually mandatory. Supabase rejects test numbers without one. Sensible, and it creates a hazard: when the date passes your test numbers stop working silently, with no deployment and no code change. If your next submission lands after it, you will debug a login failure with no proximate cause. Calendar the date with a reminder ahead of it.
Config changes are not instant. The running service may take a minute or two to observe them, during which a newly registered test number still behaves as an ordinary number and dispatches a real message. You add a number, test immediately, the fixed code is rejected, and you conclude the config did not save. It did. Wait, retry, then investigate.
These are permanent credentials. A number mapped to a fixed code is a username and password that never rotates, sits in plaintext in a config field, and appears in your App Review submission and any ticket referencing it. Keep the list short, scope those accounts to demo data, and never point one at an account with admin access or cross-tenant visibility. Assume every value in that map will eventually be seen outside your team.
Choosing numbers that can never reach a real person
This is the refinement that makes the arrangement safe to leave in production indefinitely.
Pick a plausible-looking number and three problems follow. During the propagation window above, that number is treated as ordinary and a real code is dispatched, so if it belongs to somebody they receive an unsolicited code from an app they have never used. Numbering plans are living documents, so a number unassigned today can be issued to a customer in two years, at which point a real person holds the phone number matching a permanent fixed-code login to your demo account, and neither of you knows. And many providers bill on submission rather than delivery, so undeliverable attempts still cost money.
So draw from a range that cannot be allocated to a subscriber.
| 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 |
Where your country publishes no reserved range, the general method works everywhere. Get the regulator’s published numbering plan (TRAI in India, Ofcom in the UK, NANPA for North America). Find a prefix inside the mobile range that is currently unallocated and produces numbers of the correct length, so your client-side validation accepts them as well-formed while the network has nowhere to route them. Take a few specific numbers from it.
The result behaves like a real number to your validation logic, terminates nowhere on the network, and has no human on the other end whose account could be compromised if someone learns both the number and the code.
Numbering plans change, so re-verify periodically. A prefix unallocated today may be assigned later, and on that day your permanent test credential is attached to a number a real person can be issued. Re-check whenever you renew the expiry date. Prefer an explicitly reserved fictional range over a merely unused one: reserved carries a commitment not to allocate, unused carries only the observation that it has not been yet.
The trap that rejects you a second time
The reviewer can now log in. You resubmit, confident, and get the same rejection. This is where subscription apps lose a week, because the failure is not in your purchase config, your paywall, or your login. It is in the state of the account you handed over.
If that account already holds an active entitlement, your paywall does not render. Your routing is working exactly as designed: entitled users are paying customers and paying customers go to the product, not a sales screen. The reviewer lands in your app, finds no purchase screen anywhere, and reports precisely that. Every part of your system behaved correctly and the rejection is still valid.
Two ways an account ends up entitled without you noticing.
Residual state from your own testing. A sandbox purchase during development, a promotional grant, a subscription that never expired. The account looks healthy because you have been using it to test the paid experience, which is exactly what makes it useless as a review account. A subtle variant: if entitlement is stored server-side against a user rather than a platform transaction, a test purchase on Android entitles the same user on iOS. Test mostly on one platform, submit on the other, and you can hand Apple an account entitled entirely by a Google Play transaction.
The sandbox subscription follows the reviewer, not the account. This is the one that produces a genuine loop. A sandbox purchase binds to the reviewer’s sandbox Apple ID. If a reviewer purchases on demo account A and you then supply a fresh account B, that same Apple ID restores the subscription onto B. Account B was clean when you made it and is entitled the moment the reviewer signs in. You can generate ten accounts and be rejected ten times, each verifiably unsubscribed when you handed it over, with nothing observable from your side explaining it.
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: Binds to the reviewer Apple ID,<br/>not to 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
Three distinct root causes produce an identical symptom, so work through them in order rather than guessing.
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["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["Sandbox Apple ID carry-over.<br/>Explain it in the reply"]
E -- No --> G["Residual entitlement from<br/>your own testing. Clear it"]
D -- No --> H{"Are the products attached<br/>to the submitted build?"}
H -- No --> I["Unattached products, so they are<br/>unavailable in sandbox and the<br/>paywall renders empty"]
H -- Yes --> J{"Can the account's role<br/>actually make a purchase?"}
J -- No --> K["Account lacks purchase authority.<br/>Button renders disabled"]
J -- Yes --> L["Investigate StoreKit and<br/>store agreement status"]
Defences. Before replying, query your entitlement API as that exact user with a real session and confirm it reports not subscribed. Do not infer from a database row, because your app may derive entitlement through joins, caching, or a third party the row does not reflect, and do not infer from memory, because the whole point is that accounts change state without you. Once a reviewer has purchased against an account, that account is spent, so keep two or three pre-provisioned spares. Where a webhook wrote a record on your side, reset it as part of a pre-submission checklist.
When you believe the reviewer’s Apple ID is carrying a prior subscription, say so plainly and describe the mechanism. Reviewers act on clear technical explanations and cannot act on what you did not tell them.
The structural fix is better than any of that: make the purchase screen reachable even for subscribed users. If the only route to your subscription options is a paywall entitled users are routed past, then by construction a subscribed reviewer cannot find your in-app purchases, and careful provisioning only avoids triggering it. A subscription or plan-management entry in settings resolves the whole class permanently, gives existing subscribers a way to manage what they pay for, and gives you somewhere to surface upgrades.
One unrelated cause presents identically: on a first submission, in-app purchase products must be submitted with the binary. Products that exist in App Store Connect but are not attached to the version under review are unavailable in sandbox, so your app queries, gets nothing, and a well-built paywall degrades to “subscriptions unavailable” while a poorly built one renders empty. Check attachment before blaming the account.
Pre-submission checklist
Run this against the exact account you are about to hand over, not one that resembles it.
- The test number signs in with its fixed code, from a device with no SIM from your market, ideally a simulator with no cellular capability
- The account resolves to a role permitted to purchase. A staff member or sub-user role renders your purchase button disabled, which reads as a broken in-app purchase and produces the same rejection
- Your entitlement API, queried with a real session for that account, reports not subscribed
- The purchase screen renders with every product visible, correctly priced, primary action enabled
- The products are attached to the build being submitted
- The Paid Applications Agreement is accepted, since paid purchases do not function until it is
- The account has enough sample data that the app does not look empty after a purchase completes
- The test number expiry date falls beyond your expected submission timeline
- Your review notes match what that specific account actually does. If the notes say subscription options appear immediately but the account hits an onboarding form first, you have invited a clarification cycle
Then run the whole flow yourself on a clean device, as the reviewer would. Not the API calls, not the components. Verifying each component establishes that each works; it does not establish that the sequence works, and the sequence is what the reviewer experiences. Client state, hydration timing, navigation guards, and caching all live in the gap between a correct API and a correct app.
What to write back
Give them a numbered path and nothing competing 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
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.
Provide exactly one account. Listing three so the reviewer has alternatives backfires: if two behave differently, for instance because they route to an onboarding form first, you created ambiguity where you meant redundancy. Keep spares internal.
State the absence of restrictions explicitly. Apple’s message asks whether access is restricted by storefront or device configuration. Answering before it is asked twice removes a round trip.
Takeaway
Phone OTP apps do not fail App Review for subtle reasons. They fail because a person in another country cannot receive your text message, and nobody noticed that was a requirement until a reviewer did.
Separate delivery from identity: your auth service does not care about geography, only message delivery does. That same separation lets you use a regional provider your platform does not natively support, covered in Supabase Auth with an unsupported SMS provider. Register test numbers with fixed codes so App Review, your CI, and new engineers all authenticate without a local SIM through your real auth path. Draw those numbers from a range that cannot be allocated.
Then check the thing everybody forgets: that the account you are about to hand over is not already subscribed. Make the call your app makes, for that account, immediately before you reply.