django-allauth gives you email verification, social login and rate limits out of the box. What it cannot tell you is that the person filling in the signup form already has three accounts, each with a fresh Gmail alias. That information lives on the device, not in the form, and it is the one thing an abuser does not rotate for free.
This guide adds a device limit to an allauth project in two hooks: a custom signup form whose clean() reads the device’s account history, and a user_signed_up receiver that links the new user to the device afterwards. It uses the pryntid Python package, which is standard-library only and installs with pip install pryntid.
The flow in one paragraph
The signup page runs the Prynt browser agent and puts the resulting requestId in a hidden field. When the form posts, Django fetches that event server-side with your secret key. The event includes accountsOnDevice: every account id you have previously linked to this device. If the count is at or above your limit, the form raises a validation error. If the signup goes through, the signal attaches the new user’s primary key to the event, so the next attempt from that device counts it. That is the whole device-based signup limit pattern; the rest is Django plumbing.
Step 1: capture the requestId in the template
Load the agent and fill the hidden field before the form submits. The CDN build exposes a global Prynt, so no bundler is required.
<script src="https://api.pryntid.com/cdn/prynt.umd.js"></script>
<script>
const agentPromise = Prynt.load({ apiKey: "pk_live_…", extendedResult: true });
document.querySelector("#signup_form").addEventListener("submit", async (e) => {
const form = e.currentTarget;
if (form.dataset.ready) return; // second pass: let it submit
e.preventDefault();
try {
const prynt = await agentPromise;
const { requestId } = await prynt.identify({ tag: { action: "signup" } });
form.querySelector("[name=prynt_request_id]").value = requestId;
} catch (err) { /* fall through: the server treats a missing id as a flag */ }
form.dataset.ready = "1";
form.submit();
});
</script>
extendedResult: true asks the API to run the Smart Signals and risk decision for this identification (on plans that include them); without it, decision stays allow and only accountsOnDevice does the work. If you override allauth’s account/signup.html, render {{ form.prynt_request_id }} inside the form. If you render fields generically with {{ form.as_p }}, the hidden input is included automatically.
Step 2: a shared client
Create one client at import time and read the secret key from the environment. The secret key (sk_…) must never reach the browser.
# accounts/prynt_client.py
import os
from prynt import PryntServer
prynt = PryntServer(secret_key=os.environ["PRYNT_SECRET_KEY"], timeout=2)
The default timeout is five seconds. On a signup form two is usually enough; you will decide below what to do when it is exceeded.
Step 3: check the device in the form’s clean()
Subclass allauth’s SignupForm and register it with ACCOUNT_FORMS. The check runs after allauth’s own validation, so you do not spend an API call on a form that already failed for a bad password.
# accounts/forms.py
import logging
from django import forms
from django.core.exceptions import ValidationError
from allauth.account.forms import SignupForm
from prynt import PryntError
from .prynt_client import prynt
log = logging.getLogger(__name__)
MAX_ACCOUNTS_PER_DEVICE = 1 # one free account per device
BLOCK_MSG = "We couldn't create an account from this device. Contact support if this is a mistake."
class PryntSignupForm(SignupForm):
prynt_request_id = forms.CharField(widget=forms.HiddenInput, required=False)
def clean(self):
cleaned = super().clean()
if self.errors:
return cleaned
self.prynt_flag = None
rid = (cleaned.get("prynt_request_id") or "").strip()
if not rid:
self.prynt_flag = "missing_request_id"
return cleaned
try:
event = prynt.get_event(rid)
except PryntError as e:
# 404: unknown id. Anything else: Prynt unreachable. Fail open, but remember it.
self.prynt_flag = "unknown_request_id" if e.status == 404 else "prynt_unavailable"
log.warning("prynt get_event failed: %s %s", e.status, e.code)
return cleaned
if event.get("linkedId"):
raise ValidationError(BLOCK_MSG) # requestId already used by another account
if event["decision"] == "block":
raise ValidationError(BLOCK_MSG)
if event["accountsOnDevice"]["count"] >= MAX_ACCOUNTS_PER_DEVICE:
raise ValidationError(BLOCK_MSG)
if event["decision"] == "challenge":
self.prynt_flag = "challenge"
return cleaned
# settings.py
ACCOUNT_FORMS = {"signup": "accounts.forms.PryntSignupForm"}
A few choices in that code are deliberate:
- The
linkedIdcheck. ArequestIddoes not expire and is not single-use, so an abuser could replay one captured from a clean session. Refusing an event that is already attached to an account closes that hole. You can also comparecreatedAtto now and reject events older than a few minutes. - Failing open. If Prynt is unreachable, the signup still succeeds and the account carries a flag. Blocking every signup during a network blip costs more than one unchecked account.
- A neutral message. Do not tell the user “this device already has two accounts.” That teaches an abuser exactly what to rotate.
Step 4: persist the flag on the user
Flags are useless if they vanish with the request. allauth calls the form’s save(request) to create the user, which is a convenient place to record them.
def save(self, request):
user = super().save(request)
if self.prynt_flag:
user.profile.signup_flag = self.prynt_flag # or a field on your custom user model
user.profile.save(update_fields=["signup_flag"])
return user
Flagged accounts can then require a verified phone number before credits unlock, or land in a review queue. The one-account-per-person guide covers how to pick the right step-up.
Step 5: link the account in user_signed_up
This is the step people skip, and skipping it silently disables the whole check: if no account is ever linked, accountsOnDevice.count stays at zero forever.
# accounts/signals.py
import logging
from django.dispatch import receiver
from allauth.account.signals import user_signed_up
from prynt import PryntError
from .prynt_client import prynt
log = logging.getLogger(__name__)
@receiver(user_signed_up)
def link_device(sender, request, user, **kwargs):
rid = (request.POST.get("prynt_request_id") or "").strip()
if not rid:
return
try:
result = prynt.update_event(rid, linked_id=str(user.pk))
user.profile.visitor_id = result["visitorId"]
user.profile.save(update_fields=["visitor_id"])
except PryntError as e:
log.warning("prynt update_event failed for user %s: %s", user.pk, e.code)
Import the module in your app’s AppConfig.ready() so the receiver is registered. update_event returns the visitorId and the refreshed accountsOnDevice, so you can store the device id on the profile without a second call.
Use the primary key or another stable internal id as the linkedId, not the email address. Emails change, and an opaque id keeps personal data out of the device graph.
Social signups
user_signed_up also fires for social accounts, but an OAuth callback is a redirect, not your form post, so request.POST will not carry the requestId. Two options work well:
- Put the identify call on the button that starts the social flow and store the
requestIdin the session before redirecting, then read it fromrequest.sessionboth in the receiver and in your social account adapter’sis_open_for_signup()check. - Accept social signups unchecked and identify on the user’s first authenticated page load with
linkedIdset, which links the device after the fact.
The first enforces the limit; the second only records it. Pick based on how much of your abuse comes through social login.
Testing it
Use a test-environment secret key in development. A secret key only sees its own environment, so test signups never pollute live device history. Sign up once, then again from the same browser in a private window with a different email: the second attempt should fail validation. The playground is useful for confirming that your browser keeps the same visitorId across those windows before you debug your own code.
Where to go next
Start with a limit of one or two and a flag-heavy policy, then read your flagged accounts for a couple of weeks before converting flags into blocks. The docs list every field on the event, and the free plan on the pricing page covers early signup volume while you tune the threshold.
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.