<?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: Temp SMS</title>
    <description>The latest articles on DEV Community by Temp SMS (@tempsmss).</description>
    <link>https://dev.to/tempsmss</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%2Fuser%2Fprofile_image%2F4228913%2F8b9ca54e-247c-4d8b-be76-f43e621c9e5b.png</url>
      <title>DEV Community: Temp SMS</title>
      <link>https://dev.to/tempsmss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tempsmss"/>
    <language>en</language>
    <item>
      <title>SMS Pumping Fraud Prevention: A Practical Guide for Developers</title>
      <dc:creator>Temp SMS</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:03:34 +0000</pubDate>
      <link>https://dev.to/tempsmss/sms-pumping-fraud-prevention-a-practical-guide-for-developers-2lle</link>
      <guid>https://dev.to/tempsmss/sms-pumping-fraud-prevention-a-practical-guide-for-developers-2lle</guid>
      <description>&lt;p&gt;SMS pumping fraud prevention comes down to one rule: never let an anonymous request trigger an SMS without limits. Attackers abuse open "send code" endpoints to fire large numbers of messages at phone number ranges that earn them a share of the fees, and you pay the bill. The defence is layered: restrict the countries you send to, rate-limit by phone number, number range, IP and session, put a bot check in front of the send action, and watch your verification conversion rate. Below is how each layer works, plus a small rate limiter you can adapt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SMS pumping is
&lt;/h2&gt;

&lt;p&gt;SMS pumping, also called SMS toll fraud or artificially inflated traffic (AIT), targets any feature that sends a text on request: sign-up codes, login OTPs, password resets, "text me a download link" widgets. The pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The attacker finds an endpoint that texts any number submitted to it.&lt;/li&gt;
&lt;li&gt;A script submits numbers in ranges where a complicit party in the delivery chain earns money per message, often in expensive destinations.&lt;/li&gt;
&lt;li&gt;The fees are shared; nobody ever enters a code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each message is cheap on its own, so the damage tends to show up on an invoice rather than as an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signs you're being pumped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sudden volume spikes to countries where you have few real users&lt;/li&gt;
&lt;li&gt;Many numbers with the same prefix, differing only in the last few digits&lt;/li&gt;
&lt;li&gt;A sharp drop in verification completion for one country or prefix&lt;/li&gt;
&lt;li&gt;Requests with no prior page views, identical user agents or rotating IPs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layer 1: geo permissions
&lt;/h2&gt;

&lt;p&gt;The cheapest control is an allowlist. If you serve a handful of countries, refuse everything else before calling your SMS provider, and disable unused destinations in the provider's console too.&lt;/p&gt;

&lt;p&gt;Be careful with &lt;code&gt;+1&lt;/code&gt;. It isn't only the US and Canada: the North American Numbering Plan includes many Caribbean countries, so a prefix check would let them all through. Check the region instead:&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;phonenumbers&lt;/span&gt;

&lt;span class="n"&gt;ALLOWED_REGIONS&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;US&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;CA&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;GB&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;NL&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;SE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;region_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e164&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;region_code_for_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;number&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;ALLOWED_REGIONS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer 2: rate limits on several keys
&lt;/h2&gt;

&lt;p&gt;A per-IP limit alone is easy to dodge with proxies. Limit several dimensions at once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;What it stops&lt;/th&gt;
&lt;th&gt;Starting point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phone number&lt;/td&gt;
&lt;td&gt;Repeated sends to one number&lt;/td&gt;
&lt;td&gt;a few per hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Number range&lt;/td&gt;
&lt;td&gt;Sequential-number attacks&lt;/td&gt;
&lt;td&gt;tens per hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP address&lt;/td&gt;
&lt;td&gt;Single-source scripts&lt;/td&gt;
&lt;td&gt;around 10 per hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session&lt;/td&gt;
&lt;td&gt;Sends without a real browsing session&lt;/td&gt;
&lt;td&gt;a few per session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global&lt;/td&gt;
&lt;td&gt;Worst-case spend&lt;/td&gt;
&lt;td&gt;above your normal peak&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tune them to your traffic. Here's a fixed-window limiter in Python with Redis that checks every key in one round trip:&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;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode_responses&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="c1"&gt;# (scope, max sends, window in seconds)
&lt;/span&gt;&lt;span class="n"&gt;RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&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;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&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;ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&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;global&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sms_allowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;subjects&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;phone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;  &lt;span class="c1"&gt;# same number minus its last 3 digits
&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&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;global&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;all&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&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;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otp:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;scope&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;subjects&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;]&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;now&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&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;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&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;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# keep INCR results, skip EXPIRE results
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every attempt counts, including refused ones, so a script hammering one number stays blocked.&lt;/li&gt;
&lt;li&gt;Fixed windows allow bursts at boundaries; a sliding window or token bucket is smoother.&lt;/li&gt;
&lt;li&gt;Log which rule fired so you can tune the limits later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Layer 3: a bot check before the send
&lt;/h2&gt;

&lt;p&gt;Put a CAPTCHA or invisible bot challenge in front of the "send code" action, not after it. You can show it only when risk signals appear: a new IP, an unusual country, several recent sends.&lt;/p&gt;

&lt;p&gt;Make the SMS the last step of the form. Validate everything else first and require a token issued when the page loads, so a script can't simply POST to your send endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4: monitor conversion, not just volume
&lt;/h2&gt;

&lt;p&gt;The clearest signal is the ratio of verified codes to sent codes. Genuine users enter most codes they request; pumped traffic enters almost none. Track it per country every hour (PostgreSQL):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;verified_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;verified_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;otp_requests&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'1 hour'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert when a country falls well below its usual rate, and keep a kill switch that stops sending to one country without a deploy. Many SMS providers also offer built-in fraud protection; treat it as one layer, not the whole defence.&lt;/p&gt;

&lt;h2&gt;
  
  
  SMS pumping fraud prevention without blocking real users
&lt;/h2&gt;

&lt;p&gt;After tightening limits, confirm that real people still get their codes. Send to genuine numbers in every country you allow; the free public numbers on &lt;a href="https://tempsmss.com/" rel="noopener noreferrer"&gt;Temp SMS&lt;/a&gt; are handy for quick manual checks of delivery and message text. If several testers use the same shared number, your per-number limit may kick in. That's the limiter working, not a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is SMS pumping the same as SMS spam?&lt;/strong&gt;&lt;br&gt;
No. Spam targets the recipient. Pumping targets the sender's wallet by generating paid traffic nobody reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does a CAPTCHA alone stop SMS pumping?&lt;/strong&gt;&lt;br&gt;
It raises the attacker's cost, but solving services exist. Combine it with geo rules, rate limits and monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which endpoints are at risk?&lt;/strong&gt;&lt;br&gt;
Any that send an SMS on request: sign-up, login, password reset, phone number change and "send me a link" forms.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More in this series on SMS verification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285"&gt;How to test SMS OTP verification without a real phone number&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/e164-phone-number-format-a-developer-cheat-sheet-3pgd"&gt;E.164 phone number format: a developer cheat sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-verification-ux-best-practices-10-tips-for-better-otp-screens-2050"&gt;SMS verification UX best practices: 10 tips for better OTP screens&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>SMS Verification UX Best Practices: 10 Tips for Better OTP Screens</title>
      <dc:creator>Temp SMS</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:01:36 +0000</pubDate>
      <link>https://dev.to/tempsmss/sms-verification-ux-best-practices-10-tips-for-better-otp-screens-2050</link>
      <guid>https://dev.to/tempsmss/sms-verification-ux-best-practices-10-tips-for-better-otp-screens-2050</guid>
      <description>&lt;p&gt;Most people who drop out of a phone sign-up don't mean to. They mistype the number, wait for a code that went somewhere else, or fight a six-box input that won't accept paste. SMS verification UX best practices come down to three things: collect the number cleanly, get the code into the field with as little typing as possible, and say clearly what to do when something goes wrong. Here are ten tips, with code.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Use the right input for the phone number
&lt;/h2&gt;

&lt;p&gt;Phone numbers aren't numbers in the maths sense. Use &lt;code&gt;type="tel"&lt;/code&gt; so mobile keyboards show a dial pad, and let the browser offer the user's saved number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Mobile number&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"phone"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;
       &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt; &lt;span class="na"&gt;inputmode=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair it with a country selector that defaults to the user's likely country, and normalise to E.164 on the server instead of forcing a format in the field.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Accept whatever people type
&lt;/h2&gt;

&lt;p&gt;Spaces, dashes, brackets, a leading &lt;code&gt;00&lt;/code&gt; or a national &lt;code&gt;0&lt;/code&gt;: people paste numbers from contacts and email signatures. Strip formatting characters before validating, and only show an error for numbers that are actually impossible, never for punctuation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Show where the code went, and make it easy to fix
&lt;/h2&gt;

&lt;p&gt;After sending, echo the number back: "We sent a code to +1 416 555 0199. &lt;strong&gt;Wrong number?&lt;/strong&gt;" The link should return to the previous step with the number pre-filled. Many "code never arrived" tickets are really typos the user never noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Let the OS fill in the code
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;autocomplete="one-time-code"&lt;/code&gt; attribute does more for completion than any custom widget. Safari on iOS and macOS suggests the code from an incoming SMS, and many Android keyboards offer it as a suggestion too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"otp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;6-digit code&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"otp"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"otp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
       &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"one-time-code"&lt;/span&gt;
       &lt;span class="na"&gt;inputmode=&lt;/span&gt;&lt;span class="s"&gt;"numeric"&lt;/span&gt; &lt;span class="na"&gt;pattern=&lt;/span&gt;&lt;span class="s"&gt;"[0-9]*"&lt;/span&gt;
       &lt;span class="na"&gt;maxlength=&lt;/span&gt;&lt;span class="s"&gt;"6"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;type="text"&lt;/code&gt; with &lt;code&gt;inputmode="numeric"&lt;/code&gt;, not &lt;code&gt;type="number"&lt;/code&gt;: number inputs can drop leading zeros, show spinner arrows and accept values like &lt;code&gt;1e5&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Bind the code to your domain
&lt;/h2&gt;

&lt;p&gt;Ending the SMS with an origin-bound line tells browsers which site the code belongs to, so it's only offered there, which also makes phishing pages less effective:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your Example code is 482913. It expires in 10 minutes.

@example.com #482913
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Chromium browsers on Android you can go further with the WebOTP API and fill the field after the user consents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OTPCredential&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#otp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;submit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&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="na"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestSubmit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* dismissed or timed out: manual entry still works */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat this as progressive enhancement. The plain input must always work on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. One field beats six boxes
&lt;/h2&gt;

&lt;p&gt;Split digit boxes look neat but break paste, autofill and screen readers unless you handle every edge case. A single input with wide letter spacing gives a similar look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nf"&gt;#otp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5em&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-variant-numeric&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tabular-nums&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you must use boxes, support pasting the full code into the first one and moving back on Backspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Add a resend cooldown with a visible countdown
&lt;/h2&gt;

&lt;p&gt;Users press "Resend" after five seconds if nothing tells them to wait. Disable it briefly, show the remaining time, and lengthen the delay after each resend, which also limits abuse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startCooldown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Resend in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nf"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Write error messages that say what to do next
&lt;/h2&gt;

&lt;p&gt;"Invalid OTP" tells the user nothing. Compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrong code:&lt;/strong&gt; "That code doesn't match. Use the latest message; older codes stop working when you request a new one."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired:&lt;/strong&gt; "This code has expired. We can send you a new one."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too many attempts:&lt;/strong&gt; "Too many tries. You can request a new code in 10 minutes."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep the typed value in the field after an error so people can spot a swapped digit.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Make it accessible
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use a visible &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;; a placeholder vanishes as soon as someone types.&lt;/li&gt;
&lt;li&gt;Announce "Code sent", the end of the countdown and errors through a polite live region, e.g. &lt;code&gt;&amp;lt;p id="otp-status" aria-live="polite"&amp;gt;&amp;lt;/p&amp;gt;&lt;/code&gt;, and connect errors to the field with &lt;code&gt;aria-describedby&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Don't expire codes so fast that slower users can't finish. State how long the code is valid and make a fresh one easy to get (WCAG's "Timing Adjustable" criterion covers this).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Test real delivery, not just staging
&lt;/h2&gt;

&lt;p&gt;Mocks and provider test numbers prove you call the API correctly, not that a real handset receives a readable message. Before launch, send to real numbers in each country you support and check timing, sender name and rendering. For quick manual checks, the free public numbers on &lt;a href="https://tempsmss.com/" rel="noopener noreferrer"&gt;Temp SMS&lt;/a&gt; show incoming texts on a web page with no account. Use them only for disposable test accounts, because anyone can read those messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  SMS verification UX best practices in short
&lt;/h2&gt;

&lt;p&gt;A clean number input, one autofill-friendly code field, the number echoed back, a visible cooldown, specific errors and real-device tests per country.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does autocomplete="one-time-code" do?&lt;/strong&gt;&lt;br&gt;
It tells the browser and OS that the field expects a one-time code, so they can suggest the code from a recent SMS instead of making the user switch apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should an SMS code stay valid?&lt;/strong&gt;&lt;br&gt;
A few minutes up to around ten is common. Show the expiry in the message and on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should the OTP field be type="number"?&lt;/strong&gt;&lt;br&gt;
No. Use &lt;code&gt;type="text"&lt;/code&gt; with &lt;code&gt;inputmode="numeric"&lt;/code&gt; to get a numeric keyboard without losing leading zeros.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More in this series on SMS verification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285"&gt;How to test SMS OTP verification without a real phone number&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/e164-phone-number-format-a-developer-cheat-sheet-3pgd"&gt;E.164 phone number format: a developer cheat sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-pumping-fraud-prevention-a-practical-guide-for-developers-2lle"&gt;SMS pumping fraud prevention: a practical guide for developers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>frontend</category>
      <category>security</category>
    </item>
    <item>
      <title>E.164 Phone Number Format: A Developer Cheat Sheet</title>
      <dc:creator>Temp SMS</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:56:39 +0000</pubDate>
      <link>https://dev.to/tempsmss/e164-phone-number-format-a-developer-cheat-sheet-3pgd</link>
      <guid>https://dev.to/tempsmss/e164-phone-number-format-a-developer-cheat-sheet-3pgd</guid>
      <description>&lt;p&gt;The E.164 phone number format is the standard way to write any phone number as one unambiguous string: a plus sign, the country code, then the national number with no spaces, punctuation or leading trunk zero, for example &lt;code&gt;+442079460958&lt;/code&gt;. It has at most 15 digits (the &lt;code&gt;+&lt;/code&gt; doesn't count). If you store, compare or text phone numbers, E.164 is the format your database and your SMS API should agree on. This cheat sheet covers the rules, country examples, normalisation code in Python and JavaScript, and the mistakes behind most phone number bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules in one minute
&lt;/h2&gt;

&lt;p&gt;E.164 is an ITU-T recommendation (&lt;a href="https://www.itu.int/rec/T-REC-E.164/" rel="noopener noreferrer"&gt;official text&lt;/a&gt;). In practice a valid E.164 string:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;starts with &lt;code&gt;+&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;continues with a country code of 1 to 3 digits, which never starts with 0&lt;/li&gt;
&lt;li&gt;ends with the national significant number: area or mobile code plus subscriber number, &lt;strong&gt;without&lt;/strong&gt; the national trunk prefix (usually &lt;code&gt;0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;contains only digits after the &lt;code&gt;+&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;is 15 digits long at most&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick structural check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^\+[1-9]\d{1,14}$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only checks shape. &lt;code&gt;+19999999999&lt;/code&gt; passes but isn't a real number, which is why you also want a library that knows each country's numbering plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  E.164 phone number format examples by country
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Written locally&lt;/th&gt;
&lt;th&gt;E.164&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;United States&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;(202) 555-0143&lt;/td&gt;
&lt;td&gt;+12025550143&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canada&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;td&gt;(416) 555-0199&lt;/td&gt;
&lt;td&gt;+14165550199&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;United Kingdom&lt;/td&gt;
&lt;td&gt;+44&lt;/td&gt;
&lt;td&gt;020 7946 0958&lt;/td&gt;
&lt;td&gt;+442079460958&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Netherlands&lt;/td&gt;
&lt;td&gt;+31&lt;/td&gt;
&lt;td&gt;06 12345678&lt;/td&gt;
&lt;td&gt;+31612345678&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finland&lt;/td&gt;
&lt;td&gt;+358&lt;/td&gt;
&lt;td&gt;040 123 4567&lt;/td&gt;
&lt;td&gt;+358401234567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sweden&lt;/td&gt;
&lt;td&gt;+46&lt;/td&gt;
&lt;td&gt;070-123 45 67&lt;/td&gt;
&lt;td&gt;+46701234567&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Belgium&lt;/td&gt;
&lt;td&gt;+32&lt;/td&gt;
&lt;td&gt;0470 12 34 56&lt;/td&gt;
&lt;td&gt;+32470123456&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The examples are for illustration only. Notice the pattern: where the national format starts with &lt;code&gt;0&lt;/code&gt;, that zero disappears in E.164. The US and Canada share &lt;code&gt;+1&lt;/code&gt;, so the country code alone can't tell you which one a number belongs to; the area code can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising in Python with phonenumbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;phonenumbers&lt;/code&gt; is a Python port of libphonenumber (&lt;code&gt;pip install phonenumbers&lt;/code&gt;).&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;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NumberParseException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PhoneNumberFormat&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;to_e164&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return an E.164 string, or None if the input isn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t a valid number.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default_region&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;NumberParseException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_valid_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PhoneNumberFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;E164&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default_region&lt;/code&gt; is a two-letter region code such as &lt;code&gt;"GB"&lt;/code&gt; or &lt;code&gt;"NL"&lt;/code&gt;, used when the input has no &lt;code&gt;+&lt;/code&gt;. Parsing a national number with the right region removes the trunk zero for you:&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="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;020 7946 0958&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;GB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PhoneNumberFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;E164&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;+442079460958&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;06 12345678&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;NL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;phonenumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format_number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PhoneNumberFormat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;E164&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;+31612345678&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;+1&lt;/code&gt; numbers, &lt;code&gt;phonenumbers.region_code_for_number(number)&lt;/code&gt; tells you whether a valid number belongs to the US, Canada or another country in the North American Numbering Plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalising in JavaScript with libphonenumber-js
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toE164&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultCountry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;defaultCountry&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already E.164&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;toE164&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0470 12 34 56&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// '+32470123456'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;phone.country&lt;/code&gt; gives you the two-letter country, which is handy for telling US and Canadian numbers apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keeping the trunk zero.&lt;/strong&gt; &lt;code&gt;+4407700900123&lt;/code&gt; is wrong; &lt;code&gt;+447700900123&lt;/code&gt; is right. Italy is the classic exception: its landline numbers keep their leading 0 after &lt;code&gt;+39&lt;/code&gt;. That's one more reason to use a library instead of &lt;code&gt;replace(/^0/, '')&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The UK "(0)".&lt;/strong&gt; Websites often show &lt;code&gt;+44 (0)20 7946 0958&lt;/code&gt;. Stripping non-digits gives &lt;code&gt;+4402079460958&lt;/code&gt;, which is wrong. Parsing libraries handle it; naive regexes don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;International dialling prefixes.&lt;/strong&gt; &lt;code&gt;0044&lt;/code&gt; (dialled from much of Europe) and &lt;code&gt;011 44&lt;/code&gt; (dialled from North America) are dialling instructions, not part of the number. Convert them to &lt;code&gt;+44&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storing numbers as integers.&lt;/strong&gt; You lose the &lt;code&gt;+&lt;/code&gt; and invite overflow and formatting bugs. Store a string; &lt;code&gt;VARCHAR(16)&lt;/code&gt; holds any E.164 value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invisible characters.&lt;/strong&gt; Pasted numbers can contain non-breaking spaces or direction marks. Normalise before comparing or looking up users, or the same person can register twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions.&lt;/strong&gt; &lt;code&gt;ext. 204&lt;/code&gt; isn't part of E.164. Keep it in a separate field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale metadata.&lt;/strong&gt; Numbering plans change. Update your phone library regularly or new number ranges will start failing validation.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Checking the result on a real network
&lt;/h2&gt;

&lt;p&gt;Unit tests catch parsing bugs, but it's worth confirming that a number normalised from user input actually receives an SMS. Free public test numbers from &lt;a href="https://tempsmss.com/" rel="noopener noreferrer"&gt;Temp SMS&lt;/a&gt; show incoming texts on a web page. For example, a &lt;a href="https://tempsmss.com/country/uk-phone-number/" rel="noopener noreferrer"&gt;UK number for receiving test SMS online&lt;/a&gt; lets you check that a &lt;code&gt;+44&lt;/code&gt; mobile round-trips through your form, your database and your SMS provider. Keep it to throwaway test accounts, since anyone can read those messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How many digits can an E.164 number have?&lt;/strong&gt;&lt;br&gt;
Up to 15, counting the country code but not the plus sign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I store the plus sign?&lt;/strong&gt;&lt;br&gt;
Yes. &lt;code&gt;+447700900123&lt;/code&gt; is unambiguous and matches what most SMS APIs expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is E.164 the same as "international format"?&lt;/strong&gt;&lt;br&gt;
Close, but not identical. International display format adds spaces (&lt;code&gt;+44 20 7946 0958&lt;/code&gt;); E.164 has none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do US and Canadian numbers both start with +1?&lt;/strong&gt;&lt;br&gt;
Yes, along with other countries in the North American Numbering Plan. Use the area code or a library's region lookup to tell them apart.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More in this series on SMS verification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285"&gt;How to test SMS OTP verification without a real phone number&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-verification-ux-best-practices-10-tips-for-better-otp-screens-2050"&gt;SMS verification UX best practices: 10 tips for better OTP screens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-pumping-fraud-prevention-a-practical-guide-for-developers-2lle"&gt;SMS pumping fraud prevention: a practical guide for developers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>python</category>
      <category>javascript</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Test SMS OTP Verification Without a Real Phone Number</title>
      <dc:creator>Temp SMS</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:22:12 +0000</pubDate>
      <link>https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285</link>
      <guid>https://dev.to/tempsmss/how-to-test-sms-otp-verification-without-a-real-phone-number-4285</guid>
      <description>&lt;p&gt;If your app sends one-time passwords by SMS, sooner or later you need to test the whole flow: the user types a phone number, your backend sends a code, the user enters it. Using your own phone for every test run is slow, burns through your SMS budget, and puts your personal number into test databases. Here are four practical ways to test SMS OTP verification without a real phone, from fully mocked to fully real.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Mock the SMS provider in unit tests
&lt;/h2&gt;

&lt;p&gt;The fastest option: don't send anything. Wrap your SMS provider behind an interface and swap in a fake during tests.&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;re&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SmsSender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeSmsSender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SmsSender&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_signup_sends_code&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;sms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeSmsSender&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+15550100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;+15550100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;\b\d{6}\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; unit tests and CI. &lt;strong&gt;Misses:&lt;/strong&gt; formatting problems, carrier filtering, delivery delays.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Use your provider's test mode
&lt;/h2&gt;

&lt;p&gt;Most providers have a sandbox. &lt;strong&gt;Firebase Authentication&lt;/strong&gt; lets you register fictional test phone numbers with a fixed code in the console (Authentication → Sign-in method → Phone → &lt;em&gt;Phone numbers for testing&lt;/em&gt;); no SMS is sent and no quota is used. &lt;strong&gt;Twilio&lt;/strong&gt; offers test credentials and "magic" numbers that simulate success and error responses for API calls without delivering real messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good for:&lt;/strong&gt; integration tests against the real SDK. &lt;strong&gt;Misses:&lt;/strong&gt; what actually happens on a real network.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Use a temporary public number for end-to-end checks
&lt;/h2&gt;

&lt;p&gt;Before a launch, or when a user reports "I never got the code", you want a real SMS on a real network. A free temporary number service such as &lt;a href="https://tempsmss.com/" rel="noopener noreferrer"&gt;Temp SMS&lt;/a&gt; shows incoming messages for public numbers in several countries on a web page, with no account required.&lt;/p&gt;

&lt;p&gt;A typical manual check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a number from the country you are testing, e.g. a &lt;a href="https://tempsmss.com/country/usa-phone-number/" rel="noopener noreferrer"&gt;US temporary number&lt;/a&gt; or a &lt;a href="https://tempsmss.com/country/canada-phone-number/" rel="noopener noreferrer"&gt;Canada receive SMS number&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Enter it in your staging sign-up form.&lt;/li&gt;
&lt;li&gt;Open the number's page and confirm the message arrives, how long it took, and that the text renders correctly (sender ID, encoding, link previews).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This catches problems mocks never will: country-specific sender ID rules, Unicode characters turning into &lt;code&gt;?&lt;/code&gt;, messages over 160 characters splitting, or a carrier silently filtering your template.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; the messages on these numbers are public. Only use them for test accounts, never for real user data, admin accounts or anything protected by 2FA.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Use a dedicated private virtual number
&lt;/h2&gt;

&lt;p&gt;For automated end-to-end tests that need real SMS, rent a private virtual number from your SMS provider and read incoming messages through its webhook or API. It costs a little each month, but messages are private and your test can assert on them programmatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach when?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Best option&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fast feedback in CI&lt;/td&gt;
&lt;td&gt;Mock the sender&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test the real SDK without cost&lt;/td&gt;
&lt;td&gt;Provider test mode / test numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check real delivery in a country before launch&lt;/td&gt;
&lt;td&gt;Temporary public number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automated real-network E2E tests&lt;/td&gt;
&lt;td&gt;Private virtual number with webhook&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Checklist for your SMS OTP flow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Codes expire (5–10 minutes is typical) and are single-use&lt;/li&gt;
&lt;li&gt;Rate-limit code requests per number and per IP&lt;/li&gt;
&lt;li&gt;Accept common input formats: with or without +, spaces and dashes&lt;/li&gt;
&lt;li&gt;Message under 160 GSM-7 characters so it isn't split&lt;/li&gt;
&lt;li&gt;Clear sender name and app name in the text&lt;/li&gt;
&lt;li&gt;A "resend code" button with a cooldown&lt;/li&gt;
&lt;li&gt;Test at least one number per country you launch in&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I test SMS verification for free?&lt;/strong&gt;&lt;br&gt;
Yes. Mocks and provider test numbers cost nothing, and free temporary numbers let you check real delivery without buying a SIM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my OTP SMS arrive in one country but not another?&lt;/strong&gt;&lt;br&gt;
Carriers apply different rules: some require registered sender IDs or templates, and some filter messages that look like spam. Test with a real number in each target country.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to use public temporary numbers?&lt;/strong&gt;&lt;br&gt;
For throwaway test accounts, yes. Never use them for production admin accounts or real customer data, because anyone can read the messages.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;More in this series on SMS verification:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/e164-phone-number-format-a-developer-cheat-sheet-3pgd"&gt;E.164 phone number format: a developer cheat sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-verification-ux-best-practices-10-tips-for-better-otp-screens-2050"&gt;SMS verification UX best practices: 10 tips for better OTP screens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tempsmss/sms-pumping-fraud-prevention-a-practical-guide-for-developers-2lle"&gt;SMS pumping fraud prevention: a practical guide for developers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
