FastAPI’s dependency injection is a natural home for a signup gate. One Depends() can read the device evidence, decide, and hand your route a verdict object, so the route itself stays about creating users. This post builds that dependency on top of the Prynt Python SDK, then links the new account to its device in a background task.
The flow in three steps
The signup-abuse check always has the same shape:
- The browser calls
identify()on the signup page and submits therequestIdwith the form. - Your server fetches that event with the secret key and decides from
decisionandaccountsOnDevice. - After the user exists, your server attaches its id to the event with
update_event, so the next signup from that device counts it.
The browser half is two lines:
const prynt = await Prynt.load({ apiKey: 'pk_live_…' });
const { requestId } = await prynt.identify({ tag: { action: 'signup' } });
// send requestId as "prynt_request_id" with the form
Everything below is the server half.
Install and configure the client
pip install pryntid
The import name is prynt. Create one client at startup and reuse it:
import os
from prynt import PryntServer, PryntError
prynt = PryntServer(secret_key=os.environ["PRYNT_SECRET_KEY"], timeout=3)
The SDK uses only the standard library and is synchronous. That matters for how you write the dependency.
The dependency
The dependency returns a small verdict object instead of raising for every outcome, so routes can treat flag and verify differently from block:
from dataclasses import dataclass, field
from fastapi import Depends, Form, HTTPException
MAX_OTHER_ACCOUNTS = 2 # the third account from one device trips the limit
@dataclass
class SignupVerdict:
action: str # "allow" | "flag" | "verify" | "block"
request_id: str | None = None
visitor_id: str | None = None
reasons: list[str] = field(default_factory=list)
def signup_guard(prynt_request_id: str | None = Form(None)) -> SignupVerdict:
if not prynt_request_id:
# Script blocked, GPC opt-out, or a client that skipped identify()
return SignupVerdict("flag", reasons=["missing_request_id"])
try:
event = prynt.get_event(prynt_request_id)
except PryntError as e:
if e.status == 404:
return SignupVerdict("flag", reasons=["unknown_request_id"])
return SignupVerdict("allow", reasons=["prynt_unavailable"]) # fail open
v = SignupVerdict("allow", prynt_request_id, event.get("visitorId"))
if event.get("linkedId"):
v.action, v.reasons = "block", ["request_id_reused"]
return v
aod = event.get("accountsOnDevice") or {}
if aod.get("count", 0) >= MAX_OTHER_ACCOUNTS:
v.action = "block"
v.reasons.append("device_account_limit")
decision = event.get("decision")
if decision == "block":
v.action = "block"
v.reasons.append("prynt_block")
elif decision == "challenge" and v.action == "allow":
v.action = "flag"
v.reasons.append("prynt_challenge")
return v
A few choices in there deserve a sentence each.
Plain def, not async def. get_event makes a blocking HTTP call. FastAPI runs sync dependencies in its threadpool, so the event loop keeps serving other requests. If you wrote async def and called the SDK directly, you would stall the loop for the length of every Prynt call.
Reused requestIds. A requestId is not single-use and does not expire. If the event already carries a linkedId, someone is replaying an identification that already created an account. Refuse it, and do not overwrite the link, since that link is the evidence your next check depends on. You may also want to reject events whose createdAt is older than a few minutes.
Missing is not the same as bad. No requestId usually means a privacy setting or a script blocker, not an attacker. Flag it rather than blocking, unless your trial is expensive enough that “no JavaScript, no account” is acceptable.
count is capped. accountsOnDevice.accounts lists at most 20 entries and sets truncated when more exist. For a limit check, count is enough.
Using it in a route
import logging
from fastapi import BackgroundTasks, FastAPI
log = logging.getLogger(__name__)
app = FastAPI()
def link_account(request_id: str, user_id: str) -> None:
try:
prynt.update_event(request_id, linked_id=user_id)
except PryntError:
log.warning("prynt link failed", extra={"request_id": request_id})
@app.post("/signup")
def signup(
background: BackgroundTasks,
email: str = Form(...),
password: str = Form(...),
verdict: SignupVerdict = Depends(signup_guard),
):
if verdict.action == "block":
raise HTTPException(403, "We couldn't create an account from this device. Contact support if this is a mistake.")
user = create_user(email, password, review=verdict.action == "flag",
needs_verification=verdict.action == "verify")
if verdict.request_id:
background.add_task(link_account, verdict.request_id, str(user.id))
return {"id": user.id}
The response returns as soon as the user exists; update_event runs afterwards. That keeps the signup fast, but it opens a small window: two signups fired in parallel from the same device can both pass before either is linked. If that race matters for your product, link synchronously, or re-check accounts later (for example, in a nightly job that reads accountsOnDevice for recent signups and flags the extras).
Store verdict.visitor_id and verdict.reasons on the user row as well. When support gets a “why was I flagged?” ticket, you want the reasons that were true at signup, not a reconstruction.
Testing the dependency
Because signup_guard is a dependency, tests can replace it without touching the network:
app.dependency_overrides[signup_guard] = lambda: SignupVerdict("block", reasons=["device_account_limit"])
That gives you one test per outcome for the route. Test the dependency itself separately by stubbing prynt.get_event with fixture events: one clean, one with three accounts on the device, one with decision: "block", one with a linkedId already set, and one raising PryntError with no status.
For an end-to-end check against the real API, sign up twice from one browser with different emails and watch the third attempt fail. The playground shows the same device’s visitorId staying put across incognito windows, which is the property the whole limit relies on.
Mistakes that quietly disable the check
Most broken signup gates fail silently, so check for these before you trust the numbers:
- Never calling
update_event. If the link step is missing or always fails,accountsOnDevice.countstays at zero forever and the limit never trips. Log the result of every link call for the first week and alert if they all fail. - Mixing environments. A secret key only sees its own environment. A browser identifying with a test public key while the server reads with a live secret key gets a 404 on every lookup, which the guard above turns into a flag for every signup.
- Trusting the browser’s verdict. The
identify()result in the browser includes a decision, but anyone can edit a form field. The dependency must fetch the event with the secret key; the browser only supplies therequestId. - Counting the new account against itself. If you re-check after linking, for example in a nightly job, exclude the user’s own
linkedIdfrom the list inaccountsOnDevice.accountsbefore comparing against the limit.
Variations worth considering
- Rate limits, not just account limits. The same event carries a
visitorIdyou can key a rate limiter on. Our post on rate limiting by device covers windows and keys. - Different limits per plan. A free tier might allow one account per device while a paid tier allows several. Make
MAX_OTHER_ACCOUNTSa parameter of a dependency factory. - Verification instead of blocking. Return
verifyfor the over-limit case and require a phone or card step before the trial unlocks. Real users with a shared laptop get through; farms pay a cost per account.
The broader Python integration, including bot checks on login and checkout, is in our Python device intelligence guide, and the signup fraud overview explains where device limits fit next to email and payment checks. Start in flag mode, read a week of verdicts, then decide which ones deserve a 403.
Try it free
Prynt is device intelligence with a free tier — visitor IDs, bot & fraud Smart Signals, and behavioral biometrics, powered by a cross-site network. Start free.