A signup from a cloud-hosted IP is almost never a real person. Nobody opens a laptop, routes through an AWS datacenter in us-east-1, and fills in your registration form by hand. Datacenter traffic at a signup endpoint is automation: scrapers, credential-stuffing scripts, mass account creation, the odd misconfigured integration.
So blocking it sounds trivial. Grab a list of cloud IP ranges, drop anything that matches, ship it. Then someone on your own team gets locked out, an entire company behind a corporate proxy can't register, and you're staring at a support ticket wondering what you broke.
The fix isn't a longer blocklist. It's gating on the right signal, and recognizing the handful of cloud IPs that are actually humans. Here's both, with code you can paste into a Node or Python signup route.
TL;DR
- A cloud/datacenter IP at signup is a strong automation-risk signal, but it does not prove the request is non-human. If your signup policy blocks cloud-origin traffic, gate on
is_cloud_provider, not on "is this user anonymized."- Keep the VPN/proxy/Tor/relay decision separate and softer. Those flags catch real, privacy-minded users, so challenge them, don't hard-block.
- Two cloud IPs you must not block: corporate secure-web-gateway egress (
is_corporate_gateway, one IP fronts a whole company) and declared crawlers (is_known_good_bot).- Use
cloud_provider_nameto log an auditable reason for every block. Do not use the provider name alone as an allowlist: shared cloud providers host many unrelated customers.- Static CIDR lists rot and know nothing about gateways or good bots. That's the failure mode behind most "it blocked my own laptop" bugs.
In practice this is one lookup and about fifteen lines of decision logic. The lookup returns a set of boolean flags for an IP; the logic decides which flags mean "a server is pretending to be a user" and which mean "a real user is behind a shared address." Get that split right and you can substantially reduce the false positives of a blunt cloud-IP block.
A datacenter IP is a different signal than a VPN
Most "block anonymous traffic" advice lumps everything together: VPN, proxy, Tor, relay, datacenter, all treated as one risk bucket. That's the mistake. These are two different axes.
A person on a corporate VPN, iCloud Private Relay, or a mobile network that happens to route through a proxy is very often a real user with a real reason to sign up. Block them and you're turning away customers. A request from an EC2 instance or a rented Hetzner box is a different thing entirely: there's no human sitting at that IP. The traffic exists because someone rented compute and pointed it at your form.
So the useful question at a signup endpoint isn't "is this IP anonymized?" It's "is this IP a server?" Those overlap sometimes, but they're not the same, and conflating them is why blunt gates generate support tickets.
The signal you want: is_cloud_provider
Any decent IP intelligence API will tell you whether an IP belongs to a hosting provider. The one I'll use here, ipgeolocation.io, returns it as a boolean plus the provider's name, alongside the rest of the risk signals in a single response. Here's the full shape from the dedicated /v3/security endpoint for a real hosting-provider IP:
{
"ip": "2.56.188.35",
"security": {
"threat_score": 35,
"is_tor": false,
"is_proxy": false,
"proxy_provider_names": [],
"proxy_confidence_score": 0,
"proxy_last_seen": "",
"is_residential_proxy": false,
"is_vpn": false,
"vpn_provider_names": [],
"vpn_confidence_score": 0,
"vpn_last_seen": "",
"is_relay": false,
"relay_provider_name": "",
"is_anonymous": false,
"is_known_attacker": true,
"is_bot": false,
"bot_confidence_score": 0,
"bot_operator_name": "",
"bot_type": "",
"is_known_good_bot": false,
"bot_last_seen": "",
"is_spam": false,
"is_cloud_provider": true,
"cloud_provider_name": "Packethub S.A.",
"is_corporate_gateway": false,
"corporate_gateway_type": "",
"corporate_gateway_provider_name": ""
}
}
The two fields this whole tutorial hangs on are is_cloud_provider and cloud_provider_name. The boolean tells you the IP belongs to cloud-provider address space; the name tells you which provider owns that space. That name is what lets you allowlist providers you trust and log a real reason when you block, instead of shipping a mystery 403. Notice this IP is also flagged is_known_attacker with a low-ish threat_score of 35, which is common for bulletproof hosts: the same address is a datacenter and a repeat offender.
/v3/security is the endpoint to reach for when security is all you need. It costs 2 credits per lookup and returns only the security object, which keeps your credit usage predictable. If you already call /v3/ipgeo for geolocation, you can add include=security and get the same object bundled with location and ASN data in one call instead of two.
Heads up: the security signals are a paid feature. A free-plan key returns
401on the security endpoint. Plans start at $19/month, which is the honest trade-off to weigh against the free alternatives below.
Before you start
You'll need Node 18+ (for global fetch) or Python 3.10+, and an API key.
Security data on ipgeolocation.io lives on the paid plans; the free plan covers basic geolocation only. Grab an API key and set it as an environment variable so it never lands in your source:
export IPGEO_API_KEY="your_key_here"
If paying for this doesn't fit, there are free options that also flag hosting IPs. ipapi.is has a free datacenter endpoint, IPLocate exposes a free is_hosting flag, and offline libraries like py-cloudip (Python) or cloud-ip-detector (PHP) match an IP against bundled CIDR data with no network call at all. They'll all tell you an IP is a datacenter. What the paid signal here adds is the provider name, the corporate-gateway and good-bot context that prevents the false positives later in this post, and a threat_score in the same response. Pick based on whether you need that context or just a boolean.
A quick smoke test against a known hosting IP before you write any code:
curl -s -X GET \
'https://api.ipgeolocation.io/v3/security?apiKey=API_KEY&ip=2.56.188.35'
You should get the security object back with is_cloud_provider set to true.
Detect a cloud-hosted signup
curl
The raw call takes an IP as a query parameter. Leave ip off entirely and the API reads the caller's own address, which is handy for a browser-side check:
# Specific IP
curl -s 'https://api.ipgeolocation.io/v3/security?apiKey=API_KEY&ip=2.56.188.35'
# Caller IP (no ip param) — returns the requester's own address
curl -s 'https://api.ipgeolocation.io/v3/security?apiKey=API_KEY'
Node.js
Two functions: one that fetches the security object, one that makes the actual decision. Keeping them separate means the decision logic is easy to unit test without hitting the network.
const IPGEO_KEY = process.env.IPGEO_API_KEY;
const SECURITY_URL = 'https://api.ipgeolocation.io/v3/security';
async function getSecurity(ip) {
if (!IPGEO_KEY) throw new Error('IPGEO_API_KEY is not set');
const url = `${SECURITY_URL}?apiKey=${IPGEO_KEY}&ip=${encodeURIComponent(ip)}`;
// 1.5s ceiling. A signup should never hang on a third-party lookup.
const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
if (!res.ok) {
// 423 = bogon/private IP, 401 = key or plan problem, 429 = quota hit.
throw new Error(`security lookup failed: HTTP ${res.status}`);
}
const data = await res.json();
return data?.security ?? null; // null if the object is missing
}
Now the decision. This is the part worth reading twice, because it's where the false positives get filtered out:
// Cloud providers you run on or trust. Matched against cloud_provider_name.
const CLOUD_ALLOWLIST = new Set(['Your Own Infra Ltd.']);
function isCloudHostedSignup(security) {
if (!security) return false; // no data: treat as not-cloud
if (!security.is_cloud_provider) return false;
// Real employees behind an enterprise proxy. One IP fronts a whole company,
// so blocking it locks out everyone at that customer.
if (security.is_corporate_gateway) return false;
// Declared crawlers (Googlebot, ChatGPT, uptime monitors). They won't sign
// up, but if you reuse this gate on other routes, don't block them.
if (security.is_known_good_bot) return false;
// Providers you've explicitly decided are fine.
if (CLOUD_ALLOWLIST.has(security.cloud_provider_name || '')) return false;
return true; // a server renting an IP, aimed at your form: gate it
}
Python
Same two-part shape. requests uses a (connect, read) timeout tuple, and .get() with a default keeps a missing field from throwing:
import os
import requests
IPGEO_KEY = os.environ.get("IPGEOLOCATION_API_KEY")
SECURITY_URL = "https://api.ipgeolocation.io/v3/security"
CLOUD_ALLOWLIST = {"Your Own Infra Ltd."}
def get_security(ip: str) -> dict | None:
if not IPGEO_KEY:
raise RuntimeError("IPGEOLOCATION_API_KEY is not set")
try:
resp = requests.get(
SECURITY_URL,
params={"apiKey": IPGEO_KEY, "ip": ip},
timeout=(1.0, 1.5), # (connect, read) seconds
)
resp.raise_for_status()
except requests.RequestException as exc:
# Log and let the caller decide fail-open vs fail-closed.
print(f"security lookup failed for {ip}: {exc}")
return None
return resp.json().get("security")
def is_cloud_hosted_signup(security: dict | None) -> bool:
if not security or not security.get("is_cloud_provider"):
return False
if security.get("is_corporate_gateway"): # real employees, shared IP
return False
if security.get("is_known_good_bot"): # declared crawler
return False
if (security.get("cloud_provider_name") or "") in CLOUD_ALLOWLIST:
return False
return True
Gate the signup route
Wiring it into Express takes three things the naive version skips: the correct client IP, a cache, and an explicit failure policy.
const express = require('express');
const app = express();
// Behind a load balancer or CDN? Tell Express how many proxies you actually
// run, then req.ip is the real client. Don't trust more hops than exist, or a
// client can spoof X-Forwarded-For and bypass the gate.
app.set('trust proxy', 1);
// Cache per IP so repeated attempts from one address don't each cost 2 credits.
// A Map is fine to start; swap for Redis once you have more than one instance.
const cache = new Map();
const TTL_MS = 10 * 60 * 1000; // 10 minutes is plenty for signup abuse
async function cachedSecurity(ip) {
const hit = cache.get(ip);
if (hit && Date.now() - hit.at < TTL_MS) return hit.security;
const security = await getSecurity(ip); // may throw
cache.set(ip, { security, at: Date.now() });
return security;
}
async function blockCloudSignups(req, res, next) {
try {
const security = await cachedSecurity(req.ip);
if (isCloudHostedSignup(security)) {
// Log the provider so the block is auditable, not a mystery 403.
console.warn(`blocked cloud signup: ${req.ip} (${security.cloud_provider_name})`);
return res.status(403).json({ error: 'Signups from hosting providers are not allowed.' });
}
} catch (err) {
// Fail OPEN: a lookup outage shouldn't stop real users signing up.
// If this endpoint is genuinely high-risk, flip this to fail closed.
console.error(`security check skipped for ${req.ip}: ${err.message}`);
}
return next();
}
app.post('/signup', blockCloudSignups, (req, res) => {
// ... your normal signup handler ...
res.status(201).json({ ok: true });
});
Fail-open is the right default for a signup form. The cost of a lookup outage blocking real users is worse than a few bot signups slipping through during the outage. If you were gating something higher-stakes, like a password reset or a payout request, you'd make the opposite call and fail closed.
Which signal should gate what
The reason to pull the full security object instead of just the cloud boolean is that one response tells you how to treat every kind of traffic, not just servers. Here's how I map the flags to actions at a signup endpoint:
Signal in security
|
What it usually means at signup | Action |
|---|---|---|
is_cloud_provider true, nothing else notable |
A server renting a datacenter IP | Block |
is_corporate_gateway true |
Real employees behind an enterprise proxy (Zscaler, Netskope) | Allow, and don't rate-limit per IP |
is_known_good_bot true |
A declared crawler (Googlebot, ChatGPT) | Ignore, it won't sign up |
is_known_attacker true, or bot_type is credential_stuffing / brute_force
|
An active attack | Block |
is_residential_proxy true |
Often abuse hiding in home IPs | Challenge, or block on high threat_score
|
is_vpn / is_proxy / is_relay / is_tor (i.e. is_anonymous) |
Frequently a real, privacy-minded user | Step-up or challenge, don't hard-block |
threat_score 80 to 100 |
Aggregated high risk | Block or send to manual review |
The row that matters most for keeping users happy is the anonymized one. is_anonymous is true whenever VPN, proxy, Tor, or relay is detected, and a lot of those are ordinary people. Treat that as a reason to add friction, not to slam the door.
The false positives that lock out real users
This is where naive cloud blocking goes wrong, and it's the reason to use context-aware data instead of a raw CIDR list.
Static lists rot, and they block your own team. The classic bug is a developer who parses a datacenter IP list, drops every match, and then can't sign in from the office because their own egress moved into a range the list now covers. Lists carry no context and go stale between updates. The security data here refreshes at least twice a day and, more importantly, tells you when a "cloud" IP is actually a corporate gateway or a known-good bot.
Corporate gateways are real employees on a shared cloud IP. Enterprise secure web gateways like Zscaler and Netskope route a whole company's browser traffic through the vendor's cloud, so it reaches you from the vendor's address space. That space is registered to the vendor, so is_cloud_provider is true, but blocking it would lock out every employee at that customer. The API marks these separately:
{
"ip": "87.58.66.106",
"security": {
"is_cloud_provider": true,
"cloud_provider_name": "Zscaler Switzerland GmbH",
"is_corporate_gateway": true,
"corporate_gateway_type": "secure_web_gateway",
"corporate_gateway_provider_name": "Zscaler",
"is_anonymous": false,
"threat_score": 5
}
}
That's why the classifier bails the moment is_corporate_gateway is true. (Full response includes the other fields; trimmed here to the ones that matter.)
Good bots run from cloud space too. Search and AI crawlers mostly operate out of rented infrastructure, so they trip is_cloud_provider. At a signup endpoint this is moot, since crawlers don't POST registration forms, but if you reuse the same gate on content or pricing pages you'd start blocking Googlebot and ChatGPT. The is_known_good_bot flag is the allowlist:
{
"ip": "4.227.36.0",
"security": {
"is_bot": true,
"bot_type": "ai_crawler",
"bot_operator_name": "ChatGPT",
"is_known_good_bot": true,
"is_cloud_provider": true,
"cloud_provider_name": "Microsoft Corporation",
"threat_score": 15
}
}
Your own automation is a cloud IP. If a partner integration or your own server-to-server flow legitimately creates accounts, it'll come from a datacenter too. Allowlist it, but prefer a shared secret or a scoped API key over an IP allowlist, since IPs change and secrets don't.
Mind the cost. Each lookup is 2 credits, so a burst from one attacker shouldn't cost you 2 credits times a thousand attempts. That's what the per-IP cache is for. Rate-limiting at the edge by ASN, in Cloudflare or your WAF, is an even cheaper first layer before the API call ever happens.
Mobile and CGNAT are not a problem here, residential proxies are. Carrier-grade NAT and mobile ranges don't read as cloud, so the gate won't misfire on phone users. The genuinely hard case is residential proxies, which look residential on purpose. That's exactly why this gate is narrow: it catches servers, and you handle residential-proxy abuse through threat_score and is_residential_proxy, not through the cloud flag.
A few things worth adding
If you want a softer version, drive it off threat_score instead of a hard block: add friction in the 45 to 79 band, block at 80 and up, and let the cloud flag push borderline cases over the line. IPv6 works identically, the flag set is the same. And the cheapest win of all is to log every cloud_provider_name you're about to block for a week before you actually turn blocking on.
Drop the classifier into your signup route, keep the anonymized-traffic decision on its own softer track, and you'll stop the datacenter bots without a queue of real users asking why they can't register. That week of logging first is worth it: you'll find your own CI, a partner's integration, and that one big customer on Zscaler before any of them becomes a support ticket.
Top comments (0)