← 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 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:

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

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.

  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 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.

// 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.