← Back to Blog

How to Let an App Store Reviewer Log Into a Phone-OTP App

Phone-OTP apps fail App Review because the reviewer can't receive your country's SMS. Test numbers with fixed codes, safe number ranges, and the entitlement trap.

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:

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.

RegionRange reserved for fictional or test use
North America, NANP555-0100 through 555-0199
United Kingdom, Ofcom07700 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.

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.

  1. Launch the app
  2. Enter mobile number: <test number>
  3. Tap Request Code
  4. Enter verification code: <fixed code>
  5. The subscription screen appears immediately, showing three auto-renewable subscriptions: <product identifiers>
  6. 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.

// frequently asked

Common questions

Can I use a wildcard or a prefix in test phone numbers?
No. Every implementation we have checked matches the complete number exactly. There is no wildcard, prefix, or regular expression support, so each number has to be listed individually. The workaround people reach for, a server endpoint that mints a session for any number matching a pattern, is an authentication backdoor rather than a test fixture. Don't build it.
Does a registered test number send a real SMS?
No. The auth service short-circuits before the delivery step, so no message is generated, your SMS provider is never contacted, and nothing is billed. As a side effect, these numbers keep working when your SMS provider or delivery integration is down, which makes them useful for CI independently of App Review.
Is a fixed test code a security risk?
It is a permanent credential and should be treated as one. Three practices keep the risk manageable: scope those accounts to demo data with no elevated permissions and no cross-tenant visibility, keep the list to a handful of entries, and draw the numbers from an unallocated or explicitly reserved range so no real person can ever be issued the matching phone number.
Why did App Review say they couldn't find my in-app purchases when the paywall works fine for me?
The most common cause is that the demo account already holds an active entitlement, so the app correctly routes past the paywall and the reviewer never reaches a purchase screen. Verify the entitlement for that specific account through the same API your app calls. The second most common cause is that the in-app purchase products weren't attached to the submitted build, which leaves them unavailable in the sandbox and makes the paywall render empty.
Why does a fresh demo account still show as subscribed?
Because a sandbox subscription binds to the reviewer's sandbox Apple ID, not to your app's user record. A purchase completed against one demo account restores onto every later account that Apple ID signs into. Describe this explicitly in your reply. It is known sandbox behaviour and reviewers act on it once it's explained.
Should I list several demo accounts in my reply?
No. Provide one account whose behaviour exactly matches the steps you describe. Multiple accounts that behave differently from each other create ambiguity where you were trying to create redundancy, and invite an extra clarification cycle. Keep spares internal for future submissions.
Does this apply to Google Play as well as the App Store?
The login half applies to both. A Play reviewer can't receive your country's SMS either, and a registered test number with a fixed code solves it the same way. The entitlement half is App Store specific in its mechanics, because the sandbox Apple ID carry-over is an Apple behaviour, but the underlying mistake, handing over an account that is already subscribed so the paywall never renders, happens on both stores.