<?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: Rahul S</title>
    <description>The latest articles on DEV Community by Rahul S (@circuit).</description>
    <link>https://dev.to/circuit</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%2F3817101%2F14921882-d660-40c3-82cd-8fa05061a939.png</url>
      <title>DEV Community: Rahul S</title>
      <link>https://dev.to/circuit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/circuit"/>
    <language>en</language>
    <item>
      <title>We audited 163,596 disposable-email domains by last-seen date. Only 867 landed in the middle.</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Tue, 08 Sep 2026 05:27:14 +0000</pubDate>
      <link>https://dev.to/circuit/we-audited-163596-disposable-email-domains-by-last-seen-date-only-867-landed-in-the-middle-4k8p</link>
      <guid>https://dev.to/circuit/we-audited-163596-disposable-email-domains-by-last-seen-date-only-867-landed-in-the-middle-4k8p</guid>
      <description>&lt;p&gt;We keep a corpus of disposable / throwaway email domains — the list an API consults when you ask it whether &lt;code&gt;x@mailinator-ish-thing.com&lt;/code&gt; is a real mailbox or a burner. On 2026-08-27 it held &lt;strong&gt;163,596 domains&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I went to write a post about the long tail of that list. Instead I spent the afternoon discovering that the number I was about to publish was wrong, and that the interesting finding was hiding in a bucket I nearly ignored.&lt;/p&gt;

&lt;p&gt;Here is the distribution of those 163,596 domains by &lt;strong&gt;when we last saw them on any source feed&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Last seen&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;under 30 days&lt;/td&gt;
&lt;td&gt;75,787&lt;/td&gt;
&lt;td&gt;46.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30–90 days&lt;/td&gt;
&lt;td&gt;303&lt;/td&gt;
&lt;td&gt;0.19%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90–180 days&lt;/td&gt;
&lt;td&gt;564&lt;/td&gt;
&lt;td&gt;0.34%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;over 180 days&lt;/td&gt;
&lt;td&gt;86,942&lt;/td&gt;
&lt;td&gt;53.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the middle two rows. &lt;strong&gt;867 domains out of 163,596 — 0.53% — are in the middle.&lt;/strong&gt; Everything else is either fresh this month or hasn't been re-confirmed in half a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decay processes don't look like this
&lt;/h2&gt;

&lt;p&gt;If disposable domains gradually aged out — registered, used, abandoned, dropped from feeds — you would expect a smooth slope. Lots in the recent buckets, fewer as you go back, a tail. Something monotonic.&lt;/p&gt;

&lt;p&gt;A distribution that is 46% at one end, 53% at the other, and 0.5% in between is not one population decaying. It is &lt;strong&gt;two populations glued together&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A live set&lt;/strong&gt; that gets refreshed on every feed pull, so its &lt;code&gt;last_seen&lt;/code&gt; is always ~now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fossil layer&lt;/strong&gt; that entered the corpus once and was never re-confirmed by anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The gap between them isn't a finding about disposable email. It's a finding about &lt;em&gt;the list&lt;/em&gt; — and the same shape will show up in yours, because most blocklists are assembled the same way: union a bunch of sources, add on sight, and never define what removal means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways I nearly published a wrong number
&lt;/h2&gt;

&lt;p&gt;This is the part worth stealing, because all three are generic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The "second" data point was the first one wearing a hat.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The corpus has an &lt;code&gt;active&lt;/code&gt; flag, and separately a &lt;code&gt;last_seen&lt;/code&gt; date. 76,090 domains are active. I was going to write "76,090 active, and 53% not seen in 180 days" as two corroborating facts.&lt;/p&gt;

&lt;p&gt;They're one fact. &lt;code&gt;(&amp;lt;30d) + (30–90d) = 75,787 + 303 = 76,090&lt;/code&gt;, exactly. And &lt;code&gt;(90–180d) + (&amp;gt;180d) = 87,506&lt;/code&gt;, which is exactly the inactive count. &lt;code&gt;active&lt;/code&gt; &lt;strong&gt;is&lt;/strong&gt; &lt;code&gt;last_seen&lt;/code&gt;, thresholded at 90 days. Reporting both is double-counting one variable and calling it agreement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A quarter of the corpus could not physically land in the bucket I was dividing by.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The snapshot is dated 2026-08-27. Domains entered the corpus like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Added&lt;/th&gt;
&lt;th&gt;Domains&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-01&lt;/td&gt;
&lt;td&gt;123,976&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-03&lt;/td&gt;
&lt;td&gt;35,563&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-04 … 2026-08&lt;/td&gt;
&lt;td&gt;4,057 combined&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two bulk imports are 97.5% of everything. Now: a domain first added in March 2026 has a maximum possible observed age of &lt;strong&gt;179 days&lt;/strong&gt; on the snapshot date. It &lt;em&gt;cannot&lt;/em&gt; appear in the "&amp;gt;180 days" bucket. Neither can anything added later. Only the January cohort — 123,976 domains — is even eligible.&lt;/p&gt;

&lt;p&gt;So "53%" divides an eligible-only numerator by an everyone denominator. Against the cohort that could actually qualify, the fossil rate is &lt;strong&gt;86,942 / 123,976 = 70.1%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The published number would have been wrong in the flattering direction, which is the direction you never catch by sniff test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The live half has no measured lifetime at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those 75,787 domains seen within 30 days are &lt;em&gt;still listed&lt;/em&gt;. Their time-to-stale is not 30 days — it's unknown, and greater than or equal to however long they've been in the corpus. That's right-censoring, and it means this data cannot support a sentence of the form "a disposable domain lasts N days." Not with a different denominator, not with better phrasing. The observation window is ~8 months old and most of the mass arrived on day one.&lt;/p&gt;

&lt;p&gt;What it &lt;em&gt;can&lt;/em&gt; support is a stock statement: on this date, this share of our corpus was stale. That's it. That's the whole claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rest of the structure says
&lt;/h2&gt;

&lt;p&gt;Two cross-sectional numbers, which don't have the duration problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;75% of the corpus rests on a single source.&lt;/strong&gt; Counting how many independent feeds list each domain: 122,685 domains (75.0%) appear on exactly one, 28,958 on two, 11,553 on three, 400 on four or five. Three quarters of the list is one source's uncorroborated say-so — and if that source stops refreshing, those domains quietly become fossils without anyone getting an alert.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8.6% of the corpus sits on five free TLDs.&lt;/strong&gt; &lt;code&gt;.ml&lt;/code&gt; 3,276, &lt;code&gt;.ga&lt;/code&gt; 3,145, &lt;code&gt;.tk&lt;/code&gt; 2,712, &lt;code&gt;.cf&lt;/code&gt; 2,651, &lt;code&gt;.gq&lt;/code&gt; 2,350 — 14,134 domains on the free-registration TLDs, versus 27,907 on &lt;code&gt;.com&lt;/code&gt; (17.1%). Free-registration programs on those TLDs have been widely reported as wound down since 2023; I can't verify that from our data, but if it's right, a meaningful slice of that fossil layer is domains that can no longer be registered at all.&lt;/p&gt;

&lt;p&gt;One caveat on a number I'm &lt;em&gt;not&lt;/em&gt; leaning on: the corpus groups 10,456 domains under 1,692 parent domains, and the top parents are real burner operations (&lt;code&gt;anonbox.net&lt;/code&gt; 938 children, &lt;code&gt;emlhub.com&lt;/code&gt; 495, &lt;code&gt;laste.ml&lt;/code&gt; 423). But the extraction is naive string-splitting, not Public Suffix List aware, so entries like &lt;code&gt;com.pl&lt;/code&gt;, &lt;code&gt;edu.pl&lt;/code&gt;, &lt;code&gt;io.vn&lt;/code&gt;, &lt;code&gt;my.id&lt;/code&gt; and the dynamic-DNS host &lt;code&gt;dynv6.net&lt;/code&gt; show up as "parents" when their children are independent registrations. That's 798 domains, 7.6% of the grouped set — bounded, but it means "6.2 subdomains per parent" is an upper bound on operator concentration, not a measurement of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run a blocklist, three things to check
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Are your "two signals" one signal?&lt;/strong&gt; Check whether your active/enabled flag is derived from your timestamp. If it is, you have one variable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can every row reach every bucket?&lt;/strong&gt; Any duration bucket wider than the age of your newest bulk import silently excludes rows. Divide by what's eligible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does a still-present row mean?&lt;/strong&gt; If your live entries have an unbounded age, you cannot publish a lifetime. Publish a snapshot share and say so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the practical one: a fossil layer isn't free. Every stale domain is false-positive surface — a domain that stopped being disposable but never left your list is a real user getting bounced at signup. We don't have data on how often that fires, so I'm not going to pretend to quantify it. But 53% of a list that nothing has re-confirmed in six months is a number worth knowing about your own data.&lt;/p&gt;




&lt;p&gt;We do disposable-domain and IP checks as an API — &lt;a href="https://ipasis.com/docs?utm_source=linkbuilding&amp;amp;utm_medium=devto&amp;amp;utm_campaign=2026-09" rel="noopener noreferrer"&gt;the docs are here&lt;/a&gt; if you want to see the shape of it, and there's a &lt;a href="https://ipasis.com/scan?utm_source=linkbuilding&amp;amp;utm_medium=devto&amp;amp;utm_campaign=2026-09" rel="noopener noreferrer"&gt;free-tier check you can run&lt;/a&gt; without an account. Numbers in this post are aggregates over our own threat corpus; no customer data, no raw addresses.&lt;/p&gt;

&lt;p&gt;If you've audited your own blocklist by last-seen date, I'd like to know whether you got the same empty middle. My guess is you did.&lt;/p&gt;

</description>
      <category>security</category>
      <category>data</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AWS Looks Terrible on Abuse Lists. It Shouldn't. Ranking 13 Cloud Providers by Density, Not Volume.</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Sat, 05 Sep 2026 03:46:25 +0000</pubDate>
      <link>https://dev.to/circuit/aws-looks-terrible-on-abuse-lists-it-shouldnt-ranking-13-cloud-providers-by-density-not-volume-164p</link>
      <guid>https://dev.to/circuit/aws-looks-terrible-on-abuse-lists-it-shouldnt-ranking-13-cloud-providers-by-density-not-volume-164p</guid>
      <description>&lt;p&gt;Every "most malicious hosting providers" list I have ever read is really a list of who sells the most servers.&lt;/p&gt;

&lt;p&gt;We rank abuse by raw counts, publish it, and then act surprised that the leaderboard looks exactly like the cloud market-share chart. AWS shows up near the top. Of course it does. AWS announces 103 million IPv4 addresses. If one address in a million goes bad, that is still a hundred bad addresses.&lt;/p&gt;

&lt;p&gt;So we normalised it. Below is the same corpus ranked by &lt;strong&gt;abuse density&lt;/strong&gt; — flagged addresses per million addresses the provider actually announces to the internet — over 84,983 sampled IPs from our threat corpus.&lt;/p&gt;

&lt;p&gt;The ordering changes completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volume vs. density
&lt;/h2&gt;

&lt;p&gt;Ranked by raw count, the top five are DigitalOcean, OVH, GCP, Azure, AWS. That is close enough to a market-share ranking to be useless.&lt;/p&gt;

&lt;p&gt;Ranked by density, out of the 13 providers with enough observations to rank:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Flagged in sample&lt;/th&gt;
&lt;th&gt;Announced IPv4&lt;/th&gt;
&lt;th&gt;Density index (AWS = 1.0)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;DigitalOcean&lt;/td&gt;
&lt;td&gt;4,375&lt;/td&gt;
&lt;td&gt;3,139,072&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;104.5x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;OVH&lt;/td&gt;
&lt;td&gt;2,269&lt;/td&gt;
&lt;td&gt;4,866,048&lt;/td&gt;
&lt;td&gt;35.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Leaseweb&lt;/td&gt;
&lt;td&gt;1,058&lt;/td&gt;
&lt;td&gt;2,458,368&lt;/td&gt;
&lt;td&gt;32.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;M247&lt;/td&gt;
&lt;td&gt;476&lt;/td&gt;
&lt;td&gt;1,914,624&lt;/td&gt;
&lt;td&gt;18.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Linode&lt;/td&gt;
&lt;td&gt;297&lt;/td&gt;
&lt;td&gt;1,370,926&lt;/td&gt;
&lt;td&gt;16.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Hetzner&lt;/td&gt;
&lt;td&gt;601&lt;/td&gt;
&lt;td&gt;3,898,624&lt;/td&gt;
&lt;td&gt;11.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;218&lt;/td&gt;
&lt;td&gt;1,524,736&lt;/td&gt;
&lt;td&gt;10.7x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;GCP&lt;/td&gt;
&lt;td&gt;2,065&lt;/td&gt;
&lt;td&gt;19,100,576&lt;/td&gt;
&lt;td&gt;8.1x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Vultr&lt;/td&gt;
&lt;td&gt;176&lt;/td&gt;
&lt;td&gt;1,741,824&lt;/td&gt;
&lt;td&gt;7.6x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Huawei&lt;/td&gt;
&lt;td&gt;237&lt;/td&gt;
&lt;td&gt;2,872,832&lt;/td&gt;
&lt;td&gt;6.2x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;Oracle&lt;/td&gt;
&lt;td&gt;312&lt;/td&gt;
&lt;td&gt;4,320,266&lt;/td&gt;
&lt;td&gt;5.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;1,664&lt;/td&gt;
&lt;td&gt;53,919,133&lt;/td&gt;
&lt;td&gt;2.3x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;1,385&lt;/td&gt;
&lt;td&gt;103,826,882&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;AWS is &lt;strong&gt;fifth by volume and last by density&lt;/strong&gt;. Azure is fourth by volume and twelfth by density. GCP is third by volume and eighth by density. The three biggest clouds are the three cleanest per address in this dataset, and every volume-ranked list on the internet says the opposite.&lt;/p&gt;

&lt;p&gt;DigitalOcean is first on both — that one is not an artefact of scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the ordering actually tracks
&lt;/h2&gt;

&lt;p&gt;It is not "which company has worse security". Read down the list and it tracks something much more boring: &lt;strong&gt;how much friction stands between a stranger with a stolen card and a running box.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The top of the table is instant-provisioning, low-cost, card-only VPS. Minutes to a shell, a few dollars a month, minimal identity checks. The bottom is the hyperscalers, where the free tier is quota-limited, the account opening is heavier, the fraud teams are enormous, and the economics of abuse are worse because a burned account costs the attacker more to replace.&lt;/p&gt;

&lt;p&gt;Abuse density is a pricing-and-onboarding metric wearing a security costume. That is also why it is stable enough to be useful: the providers at the top are not going to fix it by buying a better firewall, because the thing driving it is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caveats, because they change how you can use this
&lt;/h2&gt;

&lt;p&gt;I would rather publish the caveats than have someone quote the table without them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a 10% sample, so treat the index as relative, not absolute.&lt;/strong&gt; The counts come from a &lt;code&gt;TABLESAMPLE SYSTEM(10)&lt;/code&gt; over feed hit rows; the denominators are full announced address space. The per-provider &lt;em&gt;ratios&lt;/em&gt; are what the sample supports. Absolute "flagged per million" figures computed this way understate true density by roughly the sampling factor, so I have deliberately published the index rather than a headline rate that would be wrong by an order of magnitude the moment someone cites it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seven providers are missing on purpose.&lt;/strong&gt; fly.io, Scaleway, UpCloud, Alibaba, Fastly, Akamai and IBM each had fewer than 100 flagged addresses in the sample. Their confidence intervals are enormous — fly.io's density interval spans 60 to 233, Alibaba's spans 6 to 55. A leaderboard position built on six observations is not a finding, it is a coin flip with a logo next to it. They are excluded rather than ranked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Announced is not the same as allocated, and allocated is not the same as in use.&lt;/strong&gt; A provider sitting on address space it has not deployed will look cleaner than it is. This is the biggest structural weakness in any density metric of this shape and I do not have a clean correction for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare's row means something different from the others.&lt;/strong&gt; Most Cloudflare-announced addresses are reverse-proxy edge, so a listing there frequently reflects a customer origin behind the proxy rather than a box rented on Cloudflare. Read row 7 as "abuse arriving through" rather than "abuse hosted at".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A feed listing is an accusation, not a conviction.&lt;/strong&gt; These are abuse, malicious, bot and open-proxy family feeds. VPN-provider ranges are excluded entirely — those are allocations, not incidents, and mixing them in is how you end up with a list that just rediscovers where the VPNs are.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually use this
&lt;/h2&gt;

&lt;p&gt;Not as a blocklist. The failure mode of a density table is someone reading row 1 and dropping DigitalOcean at the edge — which also drops a large amount of ordinary traffic, including a good share of the indie SaaS ecosystem.&lt;/p&gt;

&lt;p&gt;What density is genuinely good for is a &lt;strong&gt;prior&lt;/strong&gt;. If you already score requests, provider density is a cheap feature that is stable over months and costs one lookup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Same behavioural signal, DigitalOcean origin vs AWS origin — the first deserves a lower threshold for stepping up to a challenge, not an automatic block.&lt;/li&gt;
&lt;li&gt;Signup abuse and card testing concentrate in exactly this top-of-table band, because those attacks need cheap disposable egress.&lt;/li&gt;
&lt;li&gt;If your product genuinely serves developers, expect legitimate traffic from rows 1–6 and design for the challenge path rather than the deny path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important habit is normalising before ranking. It applies well beyond hosting: any time you rank sources of anything by raw count, you are mostly ranking their size, and the interesting signal is whatever survives dividing that out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;Feed hit rows joined to hosting CIDR ranges by containment, restricted to abuse, malicious, bot and open-proxy feed families, aggregated per provider, and divided by announced IPv4 address count. Providers under 65,536 announced addresses are out of scope; providers under 100 flagged addresses in the sample are unranked. Aggregates only — no individual addresses in the published table.&lt;/p&gt;

&lt;p&gt;If you want this signal at request time rather than as a table, that is roughly what &lt;a href="https://ipasis.com/docs?utm_source=linkbuilding&amp;amp;utm_medium=devto&amp;amp;utm_campaign=2026-09" rel="noopener noreferrer"&gt;our API&lt;/a&gt; does: it returns hosting/VPN/proxy/Tor classification plus abuse-feed presence, and you can &lt;a href="https://ipasis.com/scan?utm_source=linkbuilding&amp;amp;utm_medium=devto&amp;amp;utm_campaign=2026-09" rel="noopener noreferrer"&gt;try a lookup here&lt;/a&gt; without an account.&lt;/p&gt;

&lt;p&gt;Happy to share the per-provider numbers with anyone who wants to check the arithmetic — the caveats above are the interesting part, not the ranking.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>data</category>
      <category>api</category>
    </item>
    <item>
      <title>How Long Does a Dirty IP Stay Dirty? Measuring Decay Across 923,000 Threat-Feed Observations</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Thu, 03 Sep 2026 10:23:40 +0000</pubDate>
      <link>https://dev.to/circuit/how-long-does-a-dirty-ip-stay-dirty-measuring-decay-across-923000-threat-feed-observations-1ca7</link>
      <guid>https://dev.to/circuit/how-long-does-a-dirty-ip-stay-dirty-measuring-decay-across-923000-threat-feed-observations-1ca7</guid>
      <description>&lt;p&gt;If you consume IP threat feeds, you have made a caching decision whether you&lt;br&gt;
meant to or not. You pulled a blocklist, you stored it, and something in your&lt;br&gt;
stack decides when an entry stops counting. A TTL, a nightly refresh, a cron&lt;br&gt;
that truncates a table. That number is usually picked by feel.&lt;/p&gt;

&lt;p&gt;We had the data to check the feel, so we did. This is what the decay actually&lt;br&gt;
looks like across 923,000 IP-by-feed observations in our threat corpus, and one&lt;br&gt;
methodological trap that ate more than half of the raw data before we could use&lt;br&gt;
it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "days dirty" means here
&lt;/h2&gt;

&lt;p&gt;For every IP on every feed we ingest, we keep a &lt;code&gt;first_seen&lt;/code&gt; and a &lt;code&gt;last_seen&lt;/code&gt;.&lt;br&gt;
The span between them is how long that address stayed continuously listed on&lt;br&gt;
that feed — its dirty duration. The corpus behind this post is 2,048,481&lt;br&gt;
IP-by-feed observations, and the observation window runs from October 2025 to&lt;br&gt;
late August 2026, though most feeds joined in March 2026.&lt;/p&gt;

&lt;p&gt;Two things to keep straight, because they are different objects that happen to&lt;br&gt;
share a data type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;incident list&lt;/strong&gt; records that an address did something: attacked a
host, scraped a form, failed a thousand logins.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;infrastructure list&lt;/strong&gt; records that an address &lt;em&gt;is&lt;/em&gt; something: a VPN
egress, an open proxy, a Tor exit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost everything interesting below comes from those two behaving nothing alike.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: incident data is close to disposable
&lt;/h2&gt;

&lt;p&gt;Abuse and bot listings are far more ephemeral than most cache settings assume.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Observations&lt;/th&gt;
&lt;th&gt;Median dirty&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;th&gt;Gone within a day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Abuse / DDoS reports&lt;/td&gt;
&lt;td&gt;554,081&lt;/td&gt;
&lt;td&gt;1.0 day&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;45.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot listings (general)&lt;/td&gt;
&lt;td&gt;95,547&lt;/td&gt;
&lt;td&gt;1.0 day&lt;/td&gt;
&lt;td&gt;85 days&lt;/td&gt;
&lt;td&gt;34.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot listings (fast-expiry source)&lt;/td&gt;
&lt;td&gt;14,869&lt;/td&gt;
&lt;td&gt;0.0 days&lt;/td&gt;
&lt;td&gt;18 days&lt;/td&gt;
&lt;td&gt;72.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across all 664,497 incident-type observations, &lt;strong&gt;44.7% were gone within a&lt;br&gt;
single day&lt;/strong&gt;. The median address on the largest abuse feed we ingest is listed&lt;br&gt;
for one day and never seen again.&lt;/p&gt;

&lt;p&gt;Note the shape, though: median 1 day but p90 of 90 days. This is not a&lt;br&gt;
distribution with a meaningful average. It is two populations wearing one&lt;br&gt;
label — a large mass of one-shot addresses, almost certainly dynamic&lt;br&gt;
residential IPs and short-lived compromised hosts, plus a small hard core of&lt;br&gt;
persistent offenders that stay listed for months. Reporting a mean here would&lt;br&gt;
describe neither group.&lt;/p&gt;

&lt;p&gt;The practical consequence is uncomfortable for anyone holding this data for&lt;br&gt;
weeks: after a month, most of what you are blocking is an address that&lt;br&gt;
misbehaved once, got cleaned up or got reassigned to a different subscriber,&lt;br&gt;
and has been innocent ever since.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: infrastructure data is the opposite
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Observations&lt;/th&gt;
&lt;th&gt;Median dirty&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;th&gt;Still listed in last 7 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open proxies (community lists)&lt;/td&gt;
&lt;td&gt;214,551&lt;/td&gt;
&lt;td&gt;5.2–23.0 days&lt;/td&gt;
&lt;td&gt;74–130 days&lt;/td&gt;
&lt;td&gt;14.6–19.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tor exit nodes&lt;/td&gt;
&lt;td&gt;18,525&lt;/td&gt;
&lt;td&gt;13.5 days&lt;/td&gt;
&lt;td&gt;164.5 days&lt;/td&gt;
&lt;td&gt;44.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN ranges (provider A)&lt;/td&gt;
&lt;td&gt;13,938&lt;/td&gt;
&lt;td&gt;119.7 days&lt;/td&gt;
&lt;td&gt;147.7 days&lt;/td&gt;
&lt;td&gt;60.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN ranges (provider B)&lt;/td&gt;
&lt;td&gt;11,526&lt;/td&gt;
&lt;td&gt;7.0 days&lt;/td&gt;
&lt;td&gt;119.7 days&lt;/td&gt;
&lt;td&gt;12.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Open proxies persist for weeks rather than hours. Tor exits have a median&lt;br&gt;
around two weeks and a very long tail — a p90 of 164 days, which fits how Tor&lt;br&gt;
actually works, since a stable relay operator keeps that relay running for a&lt;br&gt;
long time.&lt;/p&gt;

&lt;p&gt;The two VPN providers disagree violently, and that is worth not smoothing over.&lt;br&gt;
One shows a median of 119.7 days; the other shows 7.0 days with 47.7% gone&lt;br&gt;
within a day. Same category, two orders of magnitude apart. The likely reason&lt;br&gt;
is that these are two different kinds of published artifact — one provider's&lt;br&gt;
list looks like a stable allocation of owned ranges, the other's looks like a&lt;br&gt;
churning pool of active egress addresses. If you treat "VPN list" as a single&lt;br&gt;
category with a single freshness assumption, one of those two will be wrong for&lt;br&gt;
you all the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The caveat that ate 55% of the data
&lt;/h2&gt;

&lt;p&gt;This is the part most posts like this leave out, and it is the part that&lt;br&gt;
determines whether you should trust the numbers above.&lt;/p&gt;

&lt;p&gt;The raw corpus was 2,048,481 observations. We used 923,037. The other&lt;br&gt;
1,125,444 — &lt;strong&gt;54.9%&lt;/strong&gt; — cannot answer this question at all, because they are&lt;br&gt;
right-censored.&lt;/p&gt;

&lt;p&gt;Right-censoring is the survival-analysis trap that lies in wait for anyone&lt;br&gt;
measuring durations from a fixed window. If an address was already listed when&lt;br&gt;
we started watching and is still listed when we stop, its measured duration is&lt;br&gt;
not its lifetime. It is the length of our observation window.&lt;/p&gt;

&lt;p&gt;The tell is unmistakable once you look for it. Seven feeds reported a median&lt;br&gt;
dirty duration exactly equal to their observation span, with a p90 identical to&lt;br&gt;
the median and 0% of addresses decaying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One large VPN provider list: 1,119,821 observations, median 147.7 days,
observation window 148 days.&lt;/li&gt;
&lt;li&gt;Six community proxy lists: median and p90 both 151.4 days, window 151 days,
0.0% gone within a day, 100% still listed in the last 7 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A distribution where the median equals the p90 equals the window length, with&lt;br&gt;
zero decay, is not a finding about how long proxies stay dirty. It is a feed&lt;br&gt;
whose contents did not change across the entire window. For the six small proxy&lt;br&gt;
lists, the most probable explanation is simply that those upstream sources have&lt;br&gt;
stopped being updated — abandoned public lists are common, and they degrade&lt;br&gt;
silently because a stale list still returns 200 OK and still parses.&lt;/p&gt;

&lt;p&gt;That single VPN list is 55% of the raw corpus on its own. Had we reported the&lt;br&gt;
naive aggregate, the headline would have been "the median IP stays dirty for&lt;br&gt;
148 days," which is not merely wrong but almost exactly backwards from the&lt;br&gt;
finding for incident data. The honest statement about those censored feeds is&lt;br&gt;
that their true median is &lt;strong&gt;unknown and greater than the window&lt;/strong&gt; — not that it&lt;br&gt;
is 148 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;p&gt;Three things follow, none of which require our product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not run one TTL across categories.&lt;/strong&gt; A retention window tuned to abuse&lt;br&gt;
data is roughly an order of magnitude too short for VPN and proxy&lt;br&gt;
infrastructure. Tuned to infrastructure, it is far too long for abuse data, and&lt;br&gt;
you will spend that surplus blocking reassigned addresses. Segment your&lt;br&gt;
retention by what the list is asserting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expire incident data aggressively and weight it by recency.&lt;/strong&gt; Given a median&lt;br&gt;
of one day, an abuse hit from three weeks ago is weak evidence. It is not zero&lt;br&gt;
evidence — the p90 tail is real — but it should not carry the same weight as a&lt;br&gt;
hit from this morning. Recency belongs in the score, not just in the cache&lt;br&gt;
eviction policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check that your feeds are still alive.&lt;/strong&gt; The six frozen lists are the cheap&lt;br&gt;
lesson here. A feed that stops updating does not throw an error; it just&lt;br&gt;
quietly becomes a fixed set of increasingly stale addresses that your system&lt;br&gt;
keeps trusting. If the contents of a list have not changed in a month, that is&lt;br&gt;
a monitoring alert, not a stable feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method notes
&lt;/h2&gt;

&lt;p&gt;Durations are &lt;code&gt;last_seen - first_seen&lt;/code&gt; per IP per feed, computed per feed and&lt;br&gt;
then grouped by category rather than pooled, since pooling lets the largest&lt;br&gt;
feed dominate the aggregate — which is precisely how the censoring problem&lt;br&gt;
above would have gone unnoticed. Medians and p90s are per-feed; ranges are&lt;br&gt;
shown where feeds in a category disagree. All figures are aggregates over our&lt;br&gt;
threat corpus; no customer traffic, no individual addresses.&lt;/p&gt;

&lt;p&gt;We build &lt;a href="https://ipasis.com/?utm_source=linkbuilding&amp;amp;utm_medium=devto&amp;amp;utm_campaign=2026-09" rel="noopener noreferrer"&gt;IPASIS&lt;/a&gt;,&lt;br&gt;
a real-time IP and email risk API, which is why we have this corpus sitting&lt;br&gt;
around. If you want to poke at the scoring directly, the&lt;br&gt;
&lt;a href="https://ipasis.com/openapi.yaml" rel="noopener noreferrer"&gt;OpenAPI spec&lt;/a&gt; is public.&lt;/p&gt;

&lt;p&gt;Happy to share the per-category breakdown in more detail if anyone wants to&lt;br&gt;
replicate this against their own feeds — the method is simple enough that you&lt;br&gt;
can run it on whatever you are already ingesting, and the censoring check is&lt;br&gt;
the only part worth being careful about.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>data</category>
      <category>api</category>
    </item>
    <item>
      <title>Credential Stuffing Doesnt Break Your Login. It Drowns It. Heres a Pre-Auth Filter.</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Sun, 09 Aug 2026 23:48:28 +0000</pubDate>
      <link>https://dev.to/circuit/probe-fh8</link>
      <guid>https://dev.to/circuit/probe-fh8</guid>
      <description>&lt;p&gt;Credential stuffing is the most boring attack in the world, which is exactly why it works. There's no exploit, no injection, no clever bypass. Someone bought a list of a few million email/password pairs from an unrelated breach, and now a botnet is quietly replaying them against your login form at a few requests per second per IP, spread across ten thousand IPs. Every single request is a &lt;em&gt;valid, well-formed login attempt&lt;/em&gt;. That's the whole problem.&lt;/p&gt;

&lt;p&gt;I want to talk about why the usual defenses miss this, and a cheap filter that actually bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why lockouts and rate limits don't catch it
&lt;/h2&gt;

&lt;p&gt;The instinct is to reach for per-account lockout ("5 failed attempts, freeze the account") and per-IP rate limiting. Both are fine, both are worth having, and neither one sees a modern stuffing run.&lt;/p&gt;

&lt;p&gt;Per-account lockout assumes the attacker hammers &lt;em&gt;one&lt;/em&gt; account. Stuffing does the opposite — it tries one password against a million &lt;em&gt;different&lt;/em&gt; accounts. Each account sees a single failed attempt. Your lockout counter never moves. Worse, if the attacker knows you lock accounts, they'll deliberately trip it to lock out your real users — you've built them a denial-of-service button.&lt;/p&gt;

&lt;p&gt;Per-IP rate limiting assumes the volume comes from one place. It doesn't. The list is fanned out across a residential proxy network, so each individual IP makes three or four requests an hour and looks exactly like a human on hotel wifi. Your threshold is either high enough to let them through or low enough to block real people on shared NATs.&lt;/p&gt;

&lt;p&gt;The reason both fail is the same: they're counting &lt;em&gt;behavior over time&lt;/em&gt; on an axis (account, IP) the attacker deliberately spreads thin. By the time you have enough events to be confident, the credentials that were going to work already worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move the decision one layer up — before the password check
&lt;/h2&gt;

&lt;p&gt;Here's the reframe. You don't need to know whether &lt;em&gt;this&lt;/em&gt; login will succeed. You need to know whether this request is worth spending a bcrypt verification on at all.&lt;/p&gt;

&lt;p&gt;A password check is expensive on purpose — that's the point of a slow hash. Credential stuffing turns that against you: every junk attempt costs you real CPU. So do the cheap check first. Before you touch the password, score the &lt;em&gt;source&lt;/em&gt;: the IP and the email address. Botnets have a texture that individual requests give away, even when the credentials are real.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The IP is far more likely to be a datacenter range, a hosting provider, or a known proxy/VPN exit than a residential connection. Real humans logging in from an AWS IP is rare; a login &lt;em&gt;wave&lt;/em&gt; from one is not.&lt;/li&gt;
&lt;li&gt;The email, in aggregate, skews toward the reused, the disposable, and the previously-flagged. A single account per real user; a botnet churns through addresses that already show up on abuse lists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither signal is proof on its own. Together, cheaply, they let you tier your response.&lt;/p&gt;

&lt;h2&gt;
  
  
  The filter, in code
&lt;/h2&gt;

&lt;p&gt;Score before the hash. Here's the shape in Express:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyPassword&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="s2"&gt;./auth.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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scoreSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`https://api.ipasis.com/v1/validate-email`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`?email=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;ip=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&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="s2"&gt;X-API-Key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IPASIS_KEY&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;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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="c1"&gt;// fail OPEN — see below&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// { score: 0..100, recommendation, ... }&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="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="c1"&gt;// timeout / network — fail open&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&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="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&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;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;scoreSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// null (scoring unavailable) =&amp;gt; ALLOW, i.e. proceed to the normal check&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;recommendation&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ALLOW&lt;/span&gt;&lt;span class="dl"&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;rec&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BLOCK&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="c1"&gt;// High-confidence bot source. Don't run the hash, don't reveal anything.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// soft, ambiguous&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyPassword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// now spend the CPU&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;ok&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid credentials&lt;/span&gt;&lt;span class="dl"&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;rec&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REVIEW&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="c1"&gt;// Middle tier: right creds, suspicious source — step up, don't wall off.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step_up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email_otp&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;issueToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The call returns in under 20ms, so it fits in the login path without users feeling it. I used &lt;a href="https://ipasis.com/scan" rel="noopener noreferrer"&gt;IPASIS&lt;/a&gt; here because a single &lt;code&gt;validate-email&lt;/code&gt; request returns both IP reputation and email risk, which keeps the pre-auth path to one round trip — and it hands you a &lt;code&gt;risk.recommendation&lt;/code&gt; of &lt;code&gt;ALLOW&lt;/code&gt; / &lt;code&gt;REVIEW&lt;/code&gt; / &lt;code&gt;BLOCK&lt;/code&gt; directly, so the tiering falls out of the response instead of you hand-tuning score cutoffs. There's a free tier (no credit card) to wire this up against your real login traffic, and the response fields are in the &lt;a href="https://ipasis.com/docs" rel="noopener noreferrer"&gt;docs&lt;/a&gt;. Any provider that gives you both signals works; the architecture is the point, not the vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that will bite you if you skip them
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fail open, never closed.&lt;/strong&gt; If the scoring call times out or errors, let the login proceed to the normal password check. A fraud filter that takes down your login when a third party has a bad day is a worse outage than the attack. Note the &lt;code&gt;?? 100&lt;/code&gt; — absence of a score means &lt;em&gt;proceed&lt;/em&gt;, not &lt;em&gt;block&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't return a clean failure to the bad tier.&lt;/strong&gt; If you respond to a low-score request with a crisp &lt;code&gt;401&lt;/code&gt;, you've built the attacker an oracle: they learn which of their IPs your filter flags and rotate away from them. Return the same soft, ambiguous &lt;code&gt;200&lt;/code&gt; you'd return for a wrong password, or a generic error. Make the filter invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Score, don't hard-block.&lt;/strong&gt; A risk score is a prior, not a verdict — which is exactly why the tiered &lt;code&gt;recommendation&lt;/code&gt; matters. Use the top tier (&lt;code&gt;BLOCK&lt;/code&gt;) to &lt;em&gt;skip the hash and stay quiet&lt;/em&gt;, use the middle (&lt;code&gt;REVIEW&lt;/code&gt;) to add a step-up — a second factor, an email OTP — rather than a wall, and let the rest straight through. Notice the step-up only fires &lt;em&gt;after&lt;/em&gt; the password already matched: a suspicious source with the correct credentials is the account-takeover case you most want a second factor on, and you're not challenging people who'd have failed anyway. Hard-blocking on a raw categorical like "datacenter IP" will eventually catch a real customer behind a corporate proxy, and you'll never hear about the ones who just gave up.&lt;/p&gt;

&lt;p&gt;The mental model that ties it together: rate limits and lockouts are &lt;em&gt;reactive&lt;/em&gt; — they need a pattern to accumulate. Scoring the source is &lt;em&gt;pre-emptive&lt;/em&gt; — it reads the texture of a single request before you've spent anything on it. Distributed stuffing is specifically designed to defeat the first kind. That's the whole reason to add the second.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>cybersecurity</category>
      <category>security</category>
    </item>
    <item>
      <title>Card-Testing Bots Will Find Your Checkout the Day You Launch. Here's the Pre-Charge Filter That Stops Them.</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Mon, 06 Jul 2026 23:55:53 +0000</pubDate>
      <link>https://dev.to/circuit/card-testing-bots-will-find-your-checkout-the-day-you-launch-heres-the-pre-charge-filter-that-596c</link>
      <guid>https://dev.to/circuit/card-testing-bots-will-find-your-checkout-the-day-you-launch-heres-the-pre-charge-filter-that-596c</guid>
      <description>&lt;p&gt;The first time you ship a public &lt;code&gt;create-payment-intent&lt;/code&gt; route, you assume the risk is someone paying with a stolen card. That happens, but it's not what wakes you up at 3am in week one. What wakes you up is a graph: a flat decline rate that suddenly spikes to 80%, thousands of $1.00 authorizations from cards you've never seen, all in a few minutes.&lt;/p&gt;

&lt;p&gt;That's card testing. Fraudsters buy a dump of stolen card numbers and need to know which ones are still live before they try to actually cash them out somewhere else. Your brand-new checkout is a free oracle: fire a tiny authorization, read the response, sort the "approved" cards from the dead ones. Your store is just the validation step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it slips past everything you already have
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. Every defense you reach for first doesn't fire, because &lt;strong&gt;each individual request is completely valid&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Amount tampering? No — they send a legitimate amount. Idempotency bug? No — every request is genuinely distinct. Webhook signature check? Never gets there; they don't care about fulfillment, they only care about the auth response. Stripe Radar? Radar is very good, but it's scoring the &lt;em&gt;charge&lt;/em&gt;, and by the time a request reaches Radar you're already being billed per authorization attempt. Enough of those and your decline rate alone can dent your account standing with the card networks, independent of any single fraudulent charge succeeding.&lt;/p&gt;

&lt;p&gt;The shape of the attack is the inverse of the bugs everyone writes about. The classic checklist — verify the webhook, don't trust client-side amounts, scope your keys — is all about a &lt;em&gt;malformed&lt;/em&gt; request the server trusts too much. Card testing is a &lt;strong&gt;perfectly well-formed request that's still hostile&lt;/strong&gt;. Correctness fixes don't touch it, because there's nothing incorrect to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap filter goes &lt;em&gt;before&lt;/em&gt; the charge
&lt;/h2&gt;

&lt;p&gt;The insight that makes this tractable: you don't have to detect a stolen card. You have to detect the &lt;em&gt;pattern&lt;/em&gt; of a bot spraying your endpoint, and that pattern is visible before you ever call &lt;code&gt;paymentIntents.create&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Card-testing waves look almost nothing like your real buyers at the network level:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They arrive from &lt;strong&gt;datacenter / hosting ranges&lt;/strong&gt; (or a rotating residential-proxy pool), not consumer ISPs.&lt;/li&gt;
&lt;li&gt;The associated emails are &lt;strong&gt;fresh throwaways&lt;/strong&gt; with no prior session, no order history, no sending reputation.&lt;/li&gt;
&lt;li&gt;They hit the create-intent route &lt;strong&gt;directly&lt;/strong&gt;, often skipping the pages a real shopper loads first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those are things a stolen &lt;em&gt;card number&lt;/em&gt; tells you. They're things the &lt;em&gt;connection and identity&lt;/em&gt; tell you. So the move is to score the source IP and email &lt;strong&gt;before&lt;/strong&gt; you spend a Stripe call on them, and only let human-looking traffic reach the payment processor at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pre-charge gate in Express
&lt;/h2&gt;

&lt;p&gt;Here's a middleware that runs in front of the payment route. I'm using &lt;a href="https://ipasis.com/scan" rel="noopener noreferrer"&gt;IPASIS&lt;/a&gt; here because it returns IP reputation and email risk in one call with sub-20ms latency, which matters when it sits on the hot path of checkout — but the shape is the same whatever you score with.&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="c1"&gt;// middleware/preChargeGate.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RISK_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://ipasis.com/api/v1/scan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;preChargeGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&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;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;RISK_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?ip=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;email=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RISK_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="c1"&gt;// keep the timeout tight — this is on the checkout hot path&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;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1500&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&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="c1"&gt;// Trust score is 0-100; low means "looks automated / high risk"&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksAutomated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trust_score&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;datacenter&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;disposable&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;looksAutomated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// DON'T hard-error with a clear "declined" — that's the exact&lt;/span&gt;
      &lt;span class="c1"&gt;// signal the tester wants. Slow them down instead.&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;202&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;processing&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;next&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fail OPEN. A risk API being slow must never block real revenue.&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pre-charge risk check skipped:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;next&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="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;preChargeGate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the payment route only ever sees traffic that already cleared the gate:&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;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&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;preChargeGate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./middleware/preChargeGate&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/create-intent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;preChargeGate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// still validate this server-side!&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;risk_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;trust_score&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;skipped&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="nx"&gt;res&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="na"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_secret&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;
  
  
  Three things that are easy to get wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't return a clean decline.&lt;/strong&gt; The whole point of card testing is reading your response. If a flagged request gets an instant, unambiguous "card declined," you've just given the bot a faster oracle. Return a soft &lt;code&gt;202&lt;/code&gt;/&lt;code&gt;processing&lt;/code&gt;, add latency, or route to a challenge — anything that makes the endpoint expensive and low-signal to probe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail open, always.&lt;/strong&gt; This gate sits in front of money. If the risk API is slow or down, the correct behavior is to let the request through to Stripe, where Radar still has your back. Blocking real checkouts because a side service hiccuped is a worse outcome than a bit of card testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Score, don't hard-block.&lt;/strong&gt; A single "datacenter IP" is not proof of fraud — plenty of legitimate users are on corporate VPNs. It's the &lt;em&gt;combination&lt;/em&gt; (datacenter range + disposable email + no prior session + burst timing) that makes a confident call. Weight the signals; don't bet everything on one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it before you wire anything
&lt;/h2&gt;

&lt;p&gt;Before writing a line of integration code, it's worth eyeballing what these signals actually look like. You can paste any IP or email into &lt;a href="https://ipasis.com/scan" rel="noopener noreferrer"&gt;ipasis.com/scan&lt;/a&gt; and see how it classifies — residential vs datacenter vs known proxy, disposable-email flag, reputation. The &lt;a href="https://ipasis.com/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; show the full response shape, and the free tier (3,000 requests/month, no card required) is plenty to prototype a checkout gate.&lt;/p&gt;

&lt;p&gt;The takeaway isn't "buy a fraud API." It's that the card-testing wave is the one attack on your checkout that your correctness fixes structurally cannot see, and the cheapest place to catch it is &lt;em&gt;one layer up&lt;/em&gt; from the charge — on the connection and the identity, before you've spent a single processor call on a request that was never going to be a customer.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>payments</category>
      <category>api</category>
    </item>
    <item>
      <title>Catch Disposable Emails Before They Wreck Your SaaS Metrics</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Fri, 10 Apr 2026 13:40:27 +0000</pubDate>
      <link>https://dev.to/circuit/catch-disposable-emails-before-they-wreck-your-saas-metrics-3ida</link>
      <guid>https://dev.to/circuit/catch-disposable-emails-before-they-wreck-your-saas-metrics-3ida</guid>
      <description>&lt;p&gt;Every SaaS founder knows the pain: your free trial signups look great on paper, but half of them are throwaway accounts using disposable emails. They inflate your metrics, abuse free tiers, and never convert.&lt;/p&gt;

&lt;p&gt;Let's fix that — with code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Disposable Email Problem
&lt;/h2&gt;

&lt;p&gt;Services like Guerrilla Mail, Temp Mail, and Mailinator let anyone generate a throwaway email in seconds. For SaaS products with free tiers, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metric pollution&lt;/strong&gt; — your signup numbers lie&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free tier abuse&lt;/strong&gt; — the same person creates 10 accounts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wasted onboarding emails&lt;/strong&gt; — bouncing into the void&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skewed cohort analysis&lt;/strong&gt; — your retention data is garbage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Traditional regex-based blocklists can't keep up. New disposable domains pop up daily. You need a real-time intelligence layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond Blocklists: Real-Time Email Intelligence
&lt;/h2&gt;

&lt;p&gt;The smarter approach combines multiple signals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Domain age&lt;/strong&gt; — disposable domains are often days or weeks old&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MX record analysis&lt;/strong&gt; — throwaway services have recognizable mail server patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Known disposable provider databases&lt;/strong&gt; — continuously updated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP reputation correlation&lt;/strong&gt; — is the signup IP also suspicious?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Checking all four gives you a confidence score, not just a binary yes/no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation: Node.js Example
&lt;/h2&gt;

&lt;p&gt;Here's a practical signup validation middleware using &lt;a href="https://ipasis.com" rel="noopener noreferrer"&gt;IPASIS&lt;/a&gt;, which bundles IP + email intelligence in a single API call:&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="c1"&gt;// middleware/validateSignup.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IPASIS_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://ipasis.com/api/v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateSignup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Single API call checks both IP reputation + email validity&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;IPASIS_API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/scan?ip=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;email=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;headers&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;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;IPASIS_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&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="c1"&gt;// Check email signals&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;disposable&lt;/span&gt;&lt;span class="p"&gt;)&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please use a permanent email address&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="c1"&gt;// Check IP signals — VPN + Tor + known bot&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;threat_score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Log for review but don't hard-block&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flagged&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`High-risk signup: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;vpn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;tor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;threat_score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threat_score&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ipasis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;next&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fail open — don't block signups if the API is unreachable&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IPASIS check failed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;next&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="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;validateSignup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using it in Express:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&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;validateSignup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./middleware/validateSignup&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validateSignup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Route to manual review queue instead of auto-activating&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;addToReviewQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ipasis&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;res&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Account pending review&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="c1"&gt;// Clean signup — proceed normally&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;res&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Welcome aboard!&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Python Flask Version
&lt;/h2&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;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;functools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;wraps&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;

&lt;span class="n"&gt;IPASIS_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://ipasis.com/api/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nd"&gt;@wraps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&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;decorated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&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;email&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="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&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;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;X-Forwarded-For&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_addr&lt;/span&gt;&lt;span class="p"&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;resp&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="nf"&gt;get&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;IPASIS_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/scan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;params&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;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;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IPASIS_KEY&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;span class="n"&gt;timeout&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="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;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;email&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="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;disposable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;Please use a permanent email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;

        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# Fail open
&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&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;decorated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Signals Matter Most?
&lt;/h2&gt;

&lt;p&gt;After analyzing thousands of signups, here's what actually predicts abuse:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Disposable email&lt;/td&gt;
&lt;td&gt;🔴 High&lt;/td&gt;
&lt;td&gt;Direct indicator of throwaway intent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email domain age &amp;lt; 7 days&lt;/td&gt;
&lt;td&gt;🟡 Medium&lt;/td&gt;
&lt;td&gt;Freshly registered domains are risky&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPN + disposable combo&lt;/td&gt;
&lt;td&gt;🔴 Critical&lt;/td&gt;
&lt;td&gt;Privacy layering = almost always abuse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tor exit node&lt;/td&gt;
&lt;td&gt;🟡 Medium&lt;/td&gt;
&lt;td&gt;Legitimate privacy users exist, but flag for review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP threat score &amp;gt; 75&lt;/td&gt;
&lt;td&gt;🟡 Medium&lt;/td&gt;
&lt;td&gt;Correlates with bot farms and proxy networks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The magic is in combining signals. A VPN user with a Gmail address? Probably fine. A VPN user with a disposable email and a threat score of 90? That's abuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;You can test any email or IP right now at &lt;a href="https://ipasis.com/scan" rel="noopener noreferrer"&gt;ipasis.com/scan&lt;/a&gt; — no account needed for the web scanner. For API access, the &lt;a href="https://ipasis.com/docs" rel="noopener noreferrer"&gt;free tier&lt;/a&gt; gives you 1,000 requests/day, which is plenty for most early-stage SaaS products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't just block — score.&lt;/strong&gt; Binary blocklists create false positives. Score-based systems let you route suspicious signups to review queues instead of hard-blocking.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Combine IP + email signals.&lt;/strong&gt; Either alone gives you half the picture. Together, they catch coordinated abuse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail open.&lt;/strong&gt; If your validation API is down, let signups through. A few bad actors are better than zero new users.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Log everything.&lt;/strong&gt; Even if you don't block, log the signals. You'll want this data when you analyze churn.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stop letting disposable emails pollute your metrics. A single API call at signup can save you hours of cleanup later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building something and fighting signup abuse? I'd love to hear what's working for you — drop a comment below.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>saas</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Detecting Bots in 2026: IP Intelligence + Email Validation in One API Call</title>
      <dc:creator>Rahul S</dc:creator>
      <pubDate>Thu, 02 Apr 2026 05:40:24 +0000</pubDate>
      <link>https://dev.to/circuit/detecting-bots-in-2026-ip-intelligence-email-validation-in-one-api-call-cjn</link>
      <guid>https://dev.to/circuit/detecting-bots-in-2026-ip-intelligence-email-validation-in-one-api-call-cjn</guid>
      <description>&lt;h2&gt;
  
  
  The Bot Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;If you're running a web app in 2026, roughly 40% of your traffic isn't human. Scrapers, credential stuffers, fake signups — they eat your bandwidth, pollute your analytics, and sometimes steal your data.&lt;/p&gt;

&lt;p&gt;Most developers slap on a CAPTCHA and call it a day. But CAPTCHAs are a UX nightmare, and sophisticated bots solve them anyway. There's a better approach: &lt;strong&gt;checking the reputation of incoming requests before they even reach your app.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Signals That Matter Most
&lt;/h2&gt;

&lt;p&gt;After building fraud detection systems for years, I've found that two data points catch 90%+ of malicious traffic:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. IP Intelligence
&lt;/h3&gt;

&lt;p&gt;Every request comes from an IP address, and that IP tells a story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is it a known proxy/VPN/Tor exit node?&lt;/strong&gt; Legitimate users sometimes use VPNs, but bots almost always do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it from a datacenter or residential ISP?&lt;/strong&gt; Most real users browse from home or mobile — datacenter IPs are a red flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Has this IP been reported for abuse?&lt;/strong&gt; Threat intelligence feeds track IPs involved in spam, attacks, and fraud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the geolocation?&lt;/strong&gt; A "US user" connecting from a hosting provider in Eastern Europe is suspicious.
&lt;/li&gt;
&lt;/ul&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;requests&lt;/span&gt;

&lt;span class="c1"&gt;# Quick IP reputation check
&lt;/span&gt;&lt;span class="n"&gt;response&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="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://ipasis.com/api/scan/ip/185.220.101.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;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;Authorization&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;Bearer YOUR_API_KEY&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Risk Score: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;risk_score&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/100&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Is VPN: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_vpn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;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="s"&gt;Is Datacenter: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_datacenter&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;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="s"&gt;Abuse Reports: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;abuse_count&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;h3&gt;
  
  
  2. Email Validation
&lt;/h3&gt;

&lt;p&gt;For signups and form submissions, the email address is your second line of defense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does the domain actually exist?&lt;/strong&gt; Disposable email services (guerrillamail, tempmail) are bot favorites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the mailbox real?&lt;/strong&gt; SMTP verification catches typos and fake addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it a known disposable/temporary email?&lt;/strong&gt; There are 10,000+ disposable domains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the domain newly registered?&lt;/strong&gt; Fresh domains often exist solely for fraud.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Email validation
&lt;/span&gt;&lt;span class="n"&gt;response&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="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://ipasis.com/api/scan/email/suspicious@tempmail.xyz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;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;Authorization&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;Bearer YOUR_API_KEY&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;email_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Valid: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_valid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;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="s"&gt;Disposable: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;is_disposable&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&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;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="s"&gt;Domain Age: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;domain_age_days&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; days&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;
  
  
  Combining Both: A Practical Middleware
&lt;/h2&gt;

&lt;p&gt;Here's a real-world pattern I use — a middleware that scores every signup request:&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;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;IPASIS_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_reputation&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="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Score a signup attempt using IP + email intelligence.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="c1"&gt;# Check IP
&lt;/span&gt;    &lt;span class="n"&gt;ip_resp&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="nf"&gt;get&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="s"&gt;https://ipasis.com/api/scan/ip/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&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;span class="n"&gt;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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IPASIS_KEY&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;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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip_resp&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;is_vpn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
        &lt;span class="n"&gt;flags&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vpn_detected&lt;/span&gt;&lt;span class="sh"&gt;"&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;ip_resp&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;is_datacenter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
        &lt;span class="n"&gt;flags&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;datacenter_ip&lt;/span&gt;&lt;span class="sh"&gt;"&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;ip_resp&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;abuse_count&lt;/span&gt;&lt;span class="sh"&gt;"&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
        &lt;span class="n"&gt;flags&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;known_abuser&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Check email
&lt;/span&gt;    &lt;span class="n"&gt;email_resp&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="nf"&gt;get&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="s"&gt;https://ipasis.com/api/scan/email/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;email&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;span class="n"&gt;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;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;IPASIS_KEY&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;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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;email_resp&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;is_disposable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
        &lt;span class="n"&gt;flags&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disposable_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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;email_resp&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;is_valid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;risk&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
        &lt;span class="n"&gt;flags&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid_email&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;risk&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;methods&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;POST&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;signup&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;remote_addr&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;json&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;email&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="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_reputation&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="n"&gt;email&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;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_score&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;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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;Signup blocked&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;reason&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;suspicious_activity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;risk_score&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;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Flag for manual review but allow
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;pending_review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Clean signup, proceed normally
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&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;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Beats CAPTCHAs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;User Friction&lt;/th&gt;
&lt;th&gt;Bot Detection Rate&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;reCAPTCHA v2&lt;/td&gt;
&lt;td&gt;High (click images)&lt;/td&gt;
&lt;td&gt;~85%&lt;/td&gt;
&lt;td&gt;2-5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reCAPTCHA v3&lt;/td&gt;
&lt;td&gt;Low (invisible)&lt;/td&gt;
&lt;td&gt;~70%&lt;/td&gt;
&lt;td&gt;1-3s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IP + Email Intelligence&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;~92%&lt;/td&gt;
&lt;td&gt;&amp;lt;200ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key advantage: &lt;strong&gt;zero user friction&lt;/strong&gt;. Your legitimate users never see a challenge. The API call happens server-side in milliseconds, and bad actors get blocked silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you want to try this approach, &lt;a href="https://ipasis.com" rel="noopener noreferrer"&gt;IPASIS&lt;/a&gt; offers a free tier with 1,000 requests/day — enough to protect a small-to-medium app. You can also test any IP or email interactively on the &lt;a href="https://ipasis.com/scan" rel="noopener noreferrer"&gt;free scanner&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://ipasis.com/docs" rel="noopener noreferrer"&gt;API docs&lt;/a&gt; cover authentication, rate limits, and response formats. The API is REST-based with JSON responses — no SDK required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't rely solely on CAPTCHAs&lt;/strong&gt; — they hurt UX and sophisticated bots bypass them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP intelligence catches infrastructure-level fraud&lt;/strong&gt; (VPNs, datacenter IPs, known abusers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email validation catches identity-level fraud&lt;/strong&gt; (disposable emails, fake domains).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combine both for a risk score&lt;/strong&gt; that lets you block, flag, or approve in real-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side checks = zero user friction&lt;/strong&gt; — your real users never know it's happening.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Bot detection doesn't have to mean annoying your users. Sometimes the smartest security is invisible.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What bot detection approach are you using? Drop a comment — I'm curious what's working for others in 2026.&lt;/em&gt;&lt;/p&gt;

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