How to block fake signups in FastAPI
To block fake signups in a FastAPI app, check the email, phone and IP of every new signup in your signup route, right before you create the user. With SignupVet that is one API call with requests, and about ten minutes of work.
Before you start
- Create a free account at signupvet.com/signup (250 checks a month, no card) and confirm your email.
- Create an API key in the dashboard.
- Add it to your server environment, never to browser code:
SIGNUP_API_KEY=sv_live_...
1. Add a small helper
This helper calls the API with a 2 second timeout and fails open: if the API is slow, cannot be reached, the key is missing or your monthly checks are used up, it returns allow, so your signup route never breaks. It only sends a value as IP when it really is an IP address.
"""Check a signup with SignupVet (pip install requests).
Fails open: if the API is slow, unreachable or returns an error, the signup is allowed.
"""
import ipaddress
import logging
import os
import requests
BASE_URL = os.environ.get("SIGNUPVET_URL", "https://www.signupvet.com")
FAIL_OPEN = {"recommendation": "allow", "risk_score": None, "reason_codes": [], "failed_open": True}
def _valid_ip(ip):
"""Only send real IP addresses (a test client or unix socket may give something else)."""
try:
return str(ipaddress.ip_address(ip.strip())) if ip else None
except ValueError:
return None
def check_signup(email=None, phone=None, ip=None, user_agent=None, timezone=None, timeout=2):
body = {k: v for k, v in {"email": email, "phone": phone, "ip": _valid_ip(ip)}.items() if v}
context = {k: v for k, v in {"user_agent": user_agent, "timezone": timezone}.items() if v}
if context:
body["context"] = context
api_key = os.environ.get("SIGNUP_API_KEY")
if not api_key:
logging.warning("SIGNUP_API_KEY is not set, allowing signup")
return dict(FAIL_OPEN)
try:
res = requests.post(
f"{BASE_URL}/score",
headers={"Authorization": f"Bearer {api_key}"},
json=body,
timeout=timeout,
)
res.raise_for_status()
return res.json()
except requests.RequestException as err:
logging.warning("SignupVet check failed (%s), allowing signup", err)
return dict(FAIL_OPEN)
if __name__ == "__main__":
result = check_signup(email="someone@mailinator.com", ip="178.197.224.1")
print(result["recommendation"], result["risk_score"], result["reason_codes"])
2. Call it in your signup route
"""FastAPI signup route with a SignupVet check.
pip install -r requirements.txt
SIGNUP_API_KEY=sv_live_... uvicorn app:app --reload
"""
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel
from signup_check import check_signup
app = FastAPI()
class Signup(BaseModel):
email: str
password: str
timezone: str | None = None
@app.post("/signup")
def signup(body: Signup, request: Request):
# Behind a proxy, run uvicorn with --proxy-headers so request.client.host is the visitor's IP.
check = check_signup(
email=body.email,
ip=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
timezone=body.timezone,
)
if check["recommendation"] == "block":
raise HTTPException(400, "We could not create your account. Please use a different email address.")
user_id = create_user(body.email, body.password) # your existing signup
if check["recommendation"] == "review":
flag_for_review(user_id, check["reason_codes"]) # e.g. no free credits until verified
return {"ok": True}
def create_user(email: str, password: str) -> str:
return "user_123"
def flag_for_review(user_id: str, reasons: list[str]) -> None:
print(f"review user {user_id}: {', '.join(reasons)}")
pip install requests fastapi uvicorn
SIGNUP_API_KEY=sv_live_... uvicorn app:app --reload
Three things matter here:
- Check before you create the user. On
blockthe account is never created, so no free credits or trial are handed out. - Keep the route a plain
def.requestswaits for the answer, and FastAPI runs plaindefroutes in a worker thread, so other requests are not held up. In anasync defroute, use an async HTTP client such ashttpx.AsyncClientinstead. - Keep the error neutral. The message does not say which signal tripped, so a real user knows what to do and a script learns nothing.
3. Send the visitor's IP, not your proxy's
Behind a load balancer or a platform like Railway, Render, Fly.io or Nginx, request.client.host is the proxy's address. Start uvicorn so it reads the visitor IP from the proxy headers:
uvicorn app:app --proxy-headers --forwarded-allow-ips="10.0.0.1"
Put your proxy's address in --forwarded-allow-ips. Use "*" only if your app cannot be reached except through that proxy, otherwise clients can fake their IP.
4. Decide what each result means for you
| recommendation | What to do |
|---|---|
allow | Create the account as usual. |
review | Create the account, but hold back free credits or the trial until the email is confirmed, or look at it manually. |
block | Do not create the account. Show a neutral message. |
For review, a common middle path is to create the account but unlock free credits only after the email is confirmed. The reason_codes tell you why, for example disposable_domain or signup_velocity_ip. All codes are listed in the documentation.
Good to know
- Django or Flask? The same
check_signup()helper works there too: call it in your signup view before you save the user. - Optional: send the browser time zone as
timezonefrom your signup form. A mismatch with the IP's country is a useful extra signal. - Using an auth provider instead of your own route? See the guides for Supabase, Clerk and Firebase.
Test it
Send a signup with test123@mailinator.com: the route answers 400 with your neutral message and no user is created. A normal address goes through with allow. We tested the example with FastAPI 0.162 and requests 2.34.
The full example is on GitHub: github.com/signupvet/examples. For the bigger picture, read how to stop fake signups.
Add it to your signup today
SignupVet checks the email, phone and IP of every new signup in one call and spots waves of signups from the same network. Free for 250 signups a month, no card needed.