<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Singapore Mobile Proxy</title>
    <description>The latest articles on DEV Community by Singapore Mobile Proxy (singaporemobileproxy).</description>
    <link>https://dev.to/singaporemobileproxy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14856%2Fea2b4129-8976-4705-b629-f0dbe0b07301.png</url>
      <title>DEV Community: Singapore Mobile Proxy</title>
      <link>https://dev.to/singaporemobileproxy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/singaporemobileproxy"/>
    <language>en</language>
    <item>
      <title>IP Geolocation Isn't Enough for Regional App Testing</title>
      <dc:creator>Xavier Fok</dc:creator>
      <pubDate>Tue, 22 Sep 2026 08:38:08 +0000</pubDate>
      <link>https://dev.to/singaporemobileproxy/ip-geolocation-isnt-enough-for-regional-app-testing-5g3c</link>
      <guid>https://dev.to/singaporemobileproxy/ip-geolocation-isnt-enough-for-regional-app-testing-5g3c</guid>
      <description>&lt;p&gt;You point your test at a proxy in the right country, the app store shows the right storefront, and you ship. Then support tickets come in from real users on actual carrier networks seeing a different payment flow than what you tested.&lt;/p&gt;

&lt;p&gt;I run a mobile proxy network on real SIM cards in Singapore, and this came out of running it. The gap usually isn't the IP's country. It's the IP's ASN classification. Platforms that do regional testing at scale check whether an IP belongs to a "hosting," "residential," or "mobile" range, and a VPN or datacenter exit almost always lands in "hosting" no matter what country it geolocates to.&lt;/p&gt;

&lt;p&gt;Here's a script that checks that classification directly instead of trusting the country field alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;PROXY_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PROXY_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. socks5h://user:pass@host:port
&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proxies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROXY_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PROXY_URL&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Language&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-SG,en;q=0.9&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="n"&gt;ip_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://ipapi.co/json/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IP:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Org:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mobile:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mobile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not reported&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  the country check passes, the classification doesn't
&lt;/h2&gt;

&lt;p&gt;When your app makes a request through a proxy, the server on the other end doesn't just read the IP's country. It reads the IP's ASN, and IP intelligence providers classify most ASN blocks as roughly "hosting," "residential," or "mobile." A VPN exit or a datacenter proxy in the right country still resolves to a "hosting" ASN, because that's genuinely what it is. A consumer SIM issued by an actual carrier resolves to "mobile," because that's genuinely what it is too.&lt;/p&gt;

&lt;p&gt;This matters because platforms that maintain IP reputation scoring (which by now includes most app stores, ad networks, and payment processors) use that classification as a signal independent of the country field. Your test session can show the correct storefront and still get routed through a different code path than a real subscriber would see, because the country check passed but the carrier check didn't. You won't notice in testing. You'll notice when a support ticket describes a payment flow you never saw.&lt;/p&gt;

&lt;h2&gt;
  
  
  reading the ASN instead of the flag
&lt;/h2&gt;

&lt;p&gt;The script above checks &lt;code&gt;org&lt;/code&gt; and &lt;code&gt;mobile&lt;/code&gt; from an IP intelligence API. That catches the obvious case: a datacenter IP shows a hosting company in &lt;code&gt;org&lt;/code&gt; and nothing in &lt;code&gt;mobile&lt;/code&gt;, a carrier IP shows the carrier's registered ASN name and &lt;code&gt;mobile: true&lt;/code&gt;. A few fields worth reading closely in the response:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;org&lt;/code&gt;: the ASN's registered organization; you want a carrier name, not a hosting company&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mobile&lt;/code&gt;: a boolean some providers report separately from &lt;code&gt;org&lt;/code&gt;, worth checking even when &lt;code&gt;org&lt;/code&gt; looks right&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;country&lt;/code&gt;: the field everyone checks first and the one that matters least here&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need to go further and confirm which carrier specifically, not just mobile-or-not, &lt;a href="https://singaporemobileproxy.com/blog/check-mobile-proxy-carrier-asn" rel="noopener noreferrer"&gt;checking a proxy's ASN before you trust it&lt;/a&gt; covers the WHOIS lookup in more detail than fits here.&lt;/p&gt;

&lt;h2&gt;
  
  
  don't stop at the storefront
&lt;/h2&gt;

&lt;p&gt;A clean ASN doesn't guarantee the rest of the flow behaves correctly. Extend the same session to check what you actually care about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# continues using `session` from above
&lt;/span&gt;
&lt;span class="n"&gt;store_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://play.google.com/store/apps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;store url:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;cdn_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CDN_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;geo_headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cf-ipcountry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-amz-cf-pop&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-geo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cdn_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;geo_headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The storefront URL tells you which region the app store resolved you into. The CDN headers tell you which edge and geo-routing bucket actually served the response, which matters if your app pulls region-specific assets or feature flags from a CDN rather than from the store itself.&lt;/p&gt;

&lt;p&gt;For flows that involve login, cart, or checkout, keep the same session and the same proxy connection for every step. A proxy that rotates IP on every new connection hands you a different IP mid-flow, breaks session cookies, and sometimes trips fraud checks that a real single-session user would never hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  the DNS leak that undoes all of it
&lt;/h2&gt;

&lt;p&gt;Worth checking explicitly: if you're using SOCKS5, make sure DNS resolution happens through the proxy, not locally. In &lt;code&gt;requests&lt;/code&gt;, that's the difference between &lt;code&gt;socks5h://&lt;/code&gt; and &lt;code&gt;socks5://&lt;/code&gt;: the &lt;code&gt;h&lt;/code&gt; routes the hostname lookup through the tunnel. Without it, your traffic exits from the right IP but the DNS query that preceded it left from your real location. Some platforms cross-reference the two and flag the mismatch even when the HTTP request itself looks clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  what actually bit us running this on real hardware
&lt;/h2&gt;

&lt;p&gt;Real carrier IPs sit behind carrier-grade NAT, and carriers reassign addresses on their own schedule as part of normal CGNAT operation. That's standard, documented behavior, not a fault in any particular proxy. The practical effect: a sticky session that's supposed to hold one IP for the length of a test can still get reassigned mid-flow if the test runs long enough, because the reassignment happens at the carrier level, underneath the proxy layer entirely. We've had multi-hour automated test runs lose their IP partway through for exactly this reason. If you're running anything longer than a few minutes against a mobile carrier IP, build in a check that confirms the IP hasn't changed before you trust a multi-step result.&lt;/p&gt;

&lt;h2&gt;
  
  
  the tradeoff you're actually making
&lt;/h2&gt;

&lt;p&gt;Datacenter proxies are faster. Sub-100ms round trips are normal. A real SIM going through an actual carrier network typically lands somewhere between 80 and 350ms depending on signal conditions at the moment you test. For geo-testing, that difference almost never matters, because you're checking correctness of a code path, not throughput. For a high-volume automated pipeline making thousands of requests an hour, it might, and that's worth weighing against how accurately the test needs to reflect what a real subscriber sees.&lt;/p&gt;

&lt;p&gt;If your platform makes any decision (payment method, feature flag, content variant) based on whether a connection looks like a real mobile subscriber, testing that decision from a hosting-classified IP will tell you the storefront works and nothing about whether the decision itself is correct.&lt;/p&gt;

</description>
      <category>python</category>
      <category>networking</category>
      <category>testing</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
