VisualEyes for Django
Add “Sign in with VisualEyes” — passwordless photo login — to any Django site. Your users keep your ordinary username/password form; a VisualEyes button beside the username field lets them sign in by recognising their own photos instead. Everything below is copy-paste and uses the same signed API as the WordPress plugin. This page assumes the full API reference on the Integration docs page; it adds the Django specifics and the fields that a robust integration needs.
ve_start view signs POST /api/v1/challenges
→ redirect the browser to the returned challenge_url → VisualEyes redirects back to your
ve_callback?result=TOKEN → your view signs POST /api/v1/verify →
passed:true → you call login(). Your users are always free; you pay ~1.5¢
per successful login.
Fastest path — install the package
The reusable app django-visualeyes (LGPL-3.0) wraps everything on this page — the
signed client, ve_start/ve_callback views, an authentication backend, and a
{% visualeyes_button %} template tag. On PyPI as
django-visualeyes; source, README and tests:
git.vercet.net/jfarrelly_vercet/django-visualeyes.
pip install django-visualeyes
# settings.py
INSTALLED_APPS += ["visualeyes"]
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"visualeyes.backends.VisualEyesBackend",
]
VISUALEYES_CLIENT_ID = os.environ["VE_CLIENT_ID"]
VISUALEYES_CLIENT_SECRET = os.environ["VE_CLIENT_SECRET"] # never in git, never in a browser
VISUALEYES_ATTEST_LOCAL_ACCOUNTS = True # validate typed names against your own users first
# urls.py
path("accounts/", include("visualeyes.urls")),Then put {% visualeyes_button %} in your login form and render Django messages
inside the login card (see “Showing errors in your login card” below). Tested on Django 6.0 with a
fully mocked 42-test suite. Prefer to see all the moving parts, or need to adapt the flow? Everything below is
the same integration as three small copy-paste files.
1 · Settings
Register your site at /client/register to get a Client ID, a
secret (shown once) and your callback origin. Keep the secret in the environment — it must
never reach a browser or your version control.
# settings.py
import os
VE_API_BASE = os.environ.get("VE_API_BASE", "https://aqa.com")
VE_CLIENT_ID = os.environ["VE_CLIENT_ID"]
VE_CLIENT_SECRET = os.environ["VE_CLIENT_SECRET"] # server-side only, never rendered
# VisualEyes accounts have no password. Log in only over HTTPS and keep cookies secure:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # if you terminate TLS at a proxy
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True2 · The signed API client
One small module signs every call. The signature is
HMAC-SHA256(secret, "<timestamp>." + raw_body) over the exact bytes you send —
re-serialising the JSON after signing breaks it. The server rejects a timestamp more than
±120 seconds off, so keep the host on NTP.
# visualeyes.py — the signed VisualEyes API client
import hashlib, hmac, json, time, urllib.error, urllib.request
from django.conf import settings
def _call(endpoint, payload):
raw = json.dumps(payload).encode()
ts = str(time.time())
mac = hmac.new(settings.VE_CLIENT_SECRET.encode(), digestmod=hashlib.sha256)
mac.update(ts.encode()); mac.update(b"."); mac.update(raw) # sign ts + "." + raw body
req = urllib.request.Request(
settings.VE_API_BASE + endpoint, data=raw, method="POST",
headers={
"Content-Type": "application/json",
"X-VE-Client-Id": settings.VE_CLIENT_ID,
"X-VE-Timestamp": ts,
"X-VE-Signature": mac.hexdigest(),
})
try:
with urllib.request.urlopen(req, timeout=15) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as e: # 4xx/5xx still carry a JSON body
try:
return e.code, json.loads(e.read() or b"{}")
except ValueError:
return e.code, {}
except (urllib.error.URLError, TimeoutError, ValueError):
return 0, {} # network/timeout — treat as "try again"
def start_challenge(user, callback_url, local_account=False):
payload = {"user": user, "callback_url": callback_url}
if local_account: # see section 5 — only for validated accounts
payload["local_account"] = True
return _call("/api/v1/challenges", payload)
def verify(result_token):
return _call("/api/v1/verify", {"result_token": result_token})3 · The two views
Start. The button re-submits your login form (the username is already typed) to this view. Validate the name against your own user table first — a name you don’t recognise never reaches VisualEyes, which keeps attackers from probing VisualEyes for who has an account.
# views.py
import logging
from django.contrib import messages
from django.contrib.auth import get_user_model, login
from django.contrib.auth.decorators import login_not_required
from django.db.models import Q
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST
from . import visualeyes as ve
logger = logging.getLogger(__name__)
@login_not_required
@require_POST
def ve_start(request):
typed = (request.POST.get("username") or "").strip()
if not typed:
messages.error(request, "Type your username first, then click the VisualEyes logo.")
return redirect("login")
User = get_user_model()
account = User.objects.filter(
Q(username__iexact=typed) | Q(email__iexact=typed), is_active=True).first()
if account is None:
# No such active local account — stop here, do NOT call VisualEyes.
messages.error(request, "That name doesn't match an active account here.")
return redirect("login")
callback = request.build_absolute_uri(reverse("ve_callback"))
# local_account=True attests "this is a real, active account on my site". VisualEyes
# opens its alias attach flow only for attested names (anti-enumeration).
status, res = ve.start_challenge(typed.lower(), callback, local_account=True)
if status == 200 and res.get("alias_offer") and res.get("register_url"):
request.session["ve_next"] = request.POST.get("next", "")
return redirect(res["register_url"]) # first time: link this name to a VE account
if status == 200 and res.get("enrolled") and res.get("challenge_url"):
request.session["ve_next"] = request.POST.get("next", "")
return redirect(res["challenge_url"]) # do the photo challenge
messages.error(request, "VisualEyes is unavailable right now — use your password.")
return redirect("login")Callback. VisualEyes returns the browser here with ?result=TOKEN. Never trust the
redirect on its own — always confirm it server-side with verify. On a pass, prefer the
alias field (it names exactly which of your accounts to sign in); fall back to the email
for a first-time VisualEyes user.
@login_not_required
def ve_callback(request):
token = request.GET.get("result", "")
if not token:
messages.error(request, "VisualEyes sign-in was cancelled.")
return redirect("login")
status, res = ve.verify(token) # single-use, short-lived token
if not (status == 200 and res.get("passed") and res.get("user")):
messages.error(request, "VisualEyes did not confirm that sign-in.")
return redirect("login")
User = get_user_model()
alias = str(res.get("alias") or "").strip()
if alias:
# Attested per-site alias: VisualEyes tells you WHICH local account this is.
user = User.objects.filter(username__iexact=alias, is_active=True).first()
if user is None: # gone since the challenge started
messages.error(request, "That account is no longer active here.")
return redirect("login")
else:
# First-time VisualEyes user keyed by email; create a passwordless account.
email = str(res["user"]).strip().lower()
user = User.objects.filter(username__iexact=email).first()
if user is not None and user.has_usable_password():
# An existing password account already owns this name. Do NOT silently sign
# the VisualEyes identity into it — that would auto-link accounts the owner
# never asked to link (and hands over any privileges the old account carries).
# Route them through password login / your own account-linking flow instead.
messages.error(request, "An account with that name already exists here — "
"log in with your password first to link VisualEyes.")
return redirect("login")
if user is None:
user = User.objects.create_user(username=email, email=email)
user.set_unusable_password() # VisualEyes-only: no password to steal
user.save()
if res.get("duress"):
# Coercion signalled out-of-band. Act server-side (alert/limit); NEVER show the browser.
logger.warning("VisualEyes duress signal for %s", user.get_username())
# Explicit backend: required when you run multiple AUTHENTICATION_BACKENDS (e.g. django-axes).
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
request.session["via_ve"] = True # set AFTER login() (it cycles the key)
nxt = request.session.pop("ve_next", "")
if nxt and url_has_allowed_host_and_scheme(nxt, allowed_hosts={request.get_host()}):
return redirect(nxt)
return redirect("home")4 · URLs & the login button
# urls.py
from django.urls import path
from . import views
urlpatterns = [
path("accounts/ve/start", views.ve_start, name="ve_start"),
path("accounts/ve/callback", views.ve_callback, name="ve_callback"),
]In your registration/login.html, the VisualEyes button is just a second submit control that
posts the same form to ve_start — no JavaScript, so it works under a strict
Content-Security-Policy. formnovalidate lets it submit without a password.
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<label for="id_username">Username</label>
<span style="display:inline-flex;align-items:center;gap:8px">
<input id="id_username" name="username" autocomplete="username" required>
<!-- VisualEyes: submits the same form (with the username) to ve_start, no password -->
<button type="submit" formaction="{% url 've_start' %}" formnovalidate
title="Sign in with VisualEyes" aria-label="Sign in with VisualEyes"
style="display:inline-flex;padding:6px;border:1px solid #d0d7e6;border-radius:10px;background:#fff;cursor:pointer">
<svg width="26" height="26" viewBox="0 0 48 48" aria-hidden="true"><defs><linearGradient id="veMark" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7fb3ff"/><stop offset="1" stop-color="#9d7bff"/></linearGradient></defs><g fill="#c3cee8"><rect x="3" y="3" width="12" height="12" rx="3"/><rect x="18" y="3" width="12" height="12" rx="3"/><rect x="33" y="3" width="12" height="12" rx="3"/><rect x="3" y="18" width="12" height="12" rx="3"/><rect x="33" y="18" width="12" height="12" rx="3"/><rect x="3" y="33" width="12" height="12" rx="3"/><rect x="18" y="33" width="12" height="12" rx="3"/><rect x="33" y="33" width="12" height="12" rx="3"/></g><rect x="15.5" y="15.5" width="17" height="17" rx="4.5" fill="url(#veMark)"/><path d="M20 24.3l2.8 2.9 5.2-5.8" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</span>
<input type="password" name="password">
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Sign in with password</button>
</form>messages framework. Render {% for message in messages %}
inside your login card, next to form.non_field_errors, so a VisualEyes error appears
where your password error does — not stranded at the top of the page. If your base template renders
messages site-wide, wrap that block in {% block messages %} and override it empty on the
login page.{% if user.has_usable_password and not request.session.via_ve %}…{% endif %}. On Django
6.2+ you can also set PasswordChangeView.usable_password_url so the change-password
URL itself redirects such users instead of showing a form they can never submit.5 · Attestation & per-site aliases
A person’s username on your site is rarely their VisualEyes email. VisualEyes bridges that with per-site aliases, gated by attestation:
- You attest first. Send
local_account: trueonly after you have confirmed the typed name is a real, active account on your site (asve_startdoes). Never attest raw user input. - First sign-in — the offer. If VisualEyes doesn’t yet know that name for your site it
returns
{"enrolled": false, "alias_offer": true, "register_url": "…/alias/new?ctx=…"}. Redirect the browser there; the person proves an existing VisualEyes account, VisualEyes permanently links your name to it, then completes the login back to your callback. - Every later sign-in. VisualEyes recognises the alias, runs the photo challenge, and
verifyreturns"alias": "<your-username>"so you sign in the exact matching local account. The billing/identityuserstays the account’s email. - Unattested unknown names get a plain
{"enrolled": false}— no offer, nothing to enumerate.
If you don’t need your own usernames, skip attestation entirely: send the person’s email as
user, and on an enrolled:false send them to https://aqa.com/invite.
6 · What verify returns
On 200 a passed login returns these fields (aliases and metadata are additive
— older integrations that read only passed/user keep working):
| Field | Meaning & what to do with it |
|---|---|
| passed | true only if the person recognised their photos. This alone gates the session. |
| user | The account’s email (or an opaque vza_… handle for a throwaway alias). This is the billing identity. |
| alias | Present for an attested per-site alias login — the username on your site to sign in. Prefer it over user. |
| duress | true = the person signalled coercion out-of-band. Act server-side (alert / limit / read-only); never reveal it to the browser. |
| new_source | true = login from an unfamiliar network/device. Advisory — a good trigger for a “new sign-in” email. |
| src_device / src_os / src_country | Best-effort provenance for that advisory. May be null. |
| assurance | "VE1" on a pass, else null. |
| stats | Round-level detail (rounds_answered, rounds_correct, misses, skips, policy) for your logs. |
| billed_credits / balance_credits | Present on a billed pass (metered sites): 15 credits (~1.5¢) and your remaining balance. |
| receipt / tid | A tamper-evident login receipt token + id, when receipts are enabled. |
passed true/false.7 · Errors you should handle
The Integration docs list the full error table; these are the ones a careful Django integration branches on:
| HTTP / error | When & response |
|---|---|
| 401 bad_signature | Clock skew > 120s, wrong secret, or the body was re-serialised after signing. Fix signing; don’t retry blindly. |
| 402 insufficient_credit | Metered balance below one login. Top up at /dash; fall back to password meanwhile. |
| 423 alias_locked | The person locked this login name. Show the message; they unlock it at VisualEyes. |
| 409 needs_reenrolment | Their photo pool is too thin. Send them to the portal_url, not a client-side login. |
| 404 unknown_result / already_verified | Result token is wrong, already used, or another client’s. Tokens are single-use — never re-verify. |
| status 0 (network/timeout) | The client returns 0, {} when the HTTP call itself failed. Do not retry the token (single-use). Show “did not confirm” and let the person sign in again — and note the verify may still have completed and been billed on the VisualEyes side; every verified login has a receipt/tid in your dashboard ledger for reconciliation. |
| 404 origin_mismatch | The callback was delivered to a different host than the challenge was minted for. Don’t proxy callbacks across hosts. |
8 · Security checklist
- Sign only on the server. The secret never reaches a browser or a mobile app. Sign the exact raw bytes.
- Keep the clock honest. NTP — the signature window is ±120 seconds.
- Confirm every login with
verify. The?result=redirect proves nothing on its own; tokens are single-use and short-lived. - Validate before you attest. Send
local_account: trueonly for a name that is a real, active account on your side. - Respect origin binding. Deliver the result to the same host the challenge was minted for; don’t relay callbacks cross-host.
- Callbacks stay on your registered origin. Your
callback_urlmust start with the callback prefix you registered (add more origins in /dash). - Passwordless accounts: create them with
set_unusable_password(), keep them non-staff, and don’t auto-link a VisualEyes identity onto a privileged existing account by email. - Never surface duress or any challenge internals to the login browser.
Reusable app
The three files above are deliberately small and framework-idiomatic, so they drop into any project. The
packaged django-visualeyes app (an include()-able URLconf, the
VISUALEYES_* settings namespace, an authentication backend, and the
{% visualeyes_button %} tag) is public — install it from
git.vercet.net/jfarrelly_vercet/django-visualeyes
(see “Fastest path” at the top). WordPress sites need none of this: the
plugin wires the same flow automatically.