Detect Duplicate Accounts at Signup with a Face Search API (Python)
Your referral bonus is being farmed. One person opens ten accounts with ten email addresses, ten prepaid SIMs and a VPN, and your fraud rules see ten different users. Email, phone and device checks fail because those sig
Your referral bonus is being farmed. One person opens ten accounts with ten email addresses, ten prepaid SIMs and a VPN, and your fraud rules see ten different users. Email, phone and device checks fail because those signals are cheap to rotate. A face is not.
The fix: index every verified user's selfie once, then search each new selfie against that index before the account is created. Below is the full check in Python, with real similarity scores from live calls.
Want to see the scores on your own photos? Try the Face Analyzer API with two photos of the same person.
Where multi-accounting costs you money
- Referral and signup bonuses (fintech, wallets, delivery apps)
- Free-trial abuse (SaaS and AI tools where a new account resets the quota)
- Banned users coming back (marketplaces, dating, gig platforms)
- Betting and gaming (bonus hunting, self-excluded players)
- Fake reviews (one person running several buyer accounts)
1:N search, not 1:1 comparison
A face comparison answers "are these two photos the same person?". Duplicate detection asks "is this person anywhere among my existing users?". A face repository answers that in one call: save each verified face once with your user ID as external_id, then search returns matching labels with a Similarity score (0 to 100).
Flow:
- Create one face repository (once)
- On signup, search the new selfie
- Match above threshold: hold for review
- No match: create the account, save the face, store the returned face ID
- No face in the photo: ask for a retake
Duplicates are never indexed, so a repeat offender cannot add a second entry.
The cURL calls
# one-time setup
curl -X POST "https://faceanalyzer-ai.p.rapidapi.com/create-facial-repository" \
-H "x-rapidapi-key: YOUR_API_KEY" \
-H "x-rapidapi-host: faceanalyzer-ai.p.rapidapi.com" \
-d "repository_id=signup-faces"
# search a new selfie
curl -X POST "https://faceanalyzer-ai.p.rapidapi.com/search-face-in-repository" \
-H "x-rapidapi-key: YOUR_API_KEY" \
-H "x-rapidapi-host: faceanalyzer-ai.p.rapidapi.com" \
-F "[email protected]" \
-F "repository_id=signup-faces"
A duplicate comes back like this (real response, trimmed):
{
"statusCode": 200,
"body": {
"FaceMatches": [
{"FaceId": "61dbcaca-...", "ExternalImageId": "user_1001", "Similarity": 99.9853744506836}
]
}
}
The signup check in Python
import requests
API = "https://faceanalyzer-ai.p.rapidapi.com"
HEADERS = {
"x-rapidapi-host": "faceanalyzer-ai.p.rapidapi.com",
"x-rapidapi-key": "YOUR_API_KEY",
}
REPO = "signup-faces"
DUPLICATE_THRESHOLD = 95.0
def search_face(selfie_path):
with open(selfie_path, "rb") as f:
r = requests.post(
f"{API}/search-face-in-repository",
headers=HEADERS,
files={"image": f},
data={"repository_id": REPO},
)
body = r.json()["body"]
if "error" in body:
raise ValueError(body["error"]) # e.g. no face in the photo
return body["FaceMatches"]
def index_face(user_id, selfie_path):
with open(selfie_path, "rb") as f:
r = requests.post(
f"{API}/save-face-in-repository",
headers=HEADERS,
files={"image": f},
data={"repository_id": REPO, "external_id": user_id, "max_faces": 1},
)
return r.json()["body"]["FacesId"][0]
def check_signup(user_id, selfie_path):
try:
matches = search_face(selfie_path)
except ValueError:
return {"decision": "retake_selfie"}
duplicates = [m for m in matches if m["Similarity"] >= DUPLICATE_THRESHOLD]
if duplicates:
return {
"decision": "review",
"matched_accounts": [
(m["ExternalImageId"], round(m["Similarity"], 2)) for m in duplicates
],
}
face_id = index_face(user_id, selfie_path)
return {"decision": "approve", "face_id": face_id}
print(check_signup("user_7731", "selfie.jpg"))
Two details the code handles: pass max_faces on the save call, and read errors from the error key inside body.
Real results
Five users indexed (anonymous stock models), then three signups:
- Second photo of user_1001 (other outfit and background): matched at 99.99, held for review
- Second photo of user_1003: matched at 100.0, held for review
- Never indexed person: no match, account created
- Photo with no face: retake requested
The full guide has the result images, the degraded-photo tests and the deletion flow.
Picking the threshold
Degraded copies of indexed people still scored 99.49 (downscaled 14x), 99.98 (heavy blur), 99.98 (grayscale) and 99.99 (rotated 90 degrees). Eleven people who were not indexed returned no match at all: the search only returns candidates above an internal floor. A threshold of 95 leaves a wide margin.
- Review, do not auto-ban: some returning users have a legitimate reason (forgotten login, new email)
- Show the reviewer the matched account and whether it is banned
- Combine with device and payment signals
Privacy and deletion
Faces used for identification are biometric data (GDPR Article 9; Illinois BIPA in the US). Ask for explicit consent, keep the face index rather than photos, label with opaque user IDs, and delete with the stored face ID:
def erase_user(face_id):
r = requests.post(
f"{API}/delete-face-from-repository",
headers=HEADERS,
data={"repository_id": REPO, "face_ids": [face_id]},
)
return r.json()["body"] # {"message": "OK"}
After deletion, a new search with the same person's photo returned an empty list.
Scaling notes
- Two calls per genuine new user (search + save), one per flagged duplicate. Logins never touch the API.
- One repository per product or region if data must stay separated.
- Keep a liveness check in front for high-risk flows.
Sources
- GDPR Article 9: special categories, including biometric data
- GDPR Article 17: right to erasure
- Illinois Biometric Information Privacy Act
👉 Grab a key from the Face Analyzer API page and run the check on a few photos of the same person tonight.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.