← Integration docs

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.

The shape of it. The browser only ever sees redirects; your Django server does all the signing. User clicks the VisualEyes button → your 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/verifypassed: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      = True

2 · 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_startno 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>
Show errors in the login card. The views above report failures through Django’s 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.
Passwordless users have no password to change. Hide any “Change password” link when the account has no usable password or this session came in through VisualEyes: {% 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:

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):

FieldMeaning & what to do with it
passedtrue only if the person recognised their photos. This alone gates the session.
userThe account’s email (or an opaque vza_… handle for a throwaway alias). This is the billing identity.
aliasPresent for an attested per-site alias login — the username on your site to sign in. Prefer it over user.
duresstrue = the person signalled coercion out-of-band. Act server-side (alert / limit / read-only); never reveal it to the browser.
new_sourcetrue = login from an unfamiliar network/device. Advisory — a good trigger for a “new sign-in” email.
src_device / src_os / src_countryBest-effort provenance for that advisory. May be null.
assurance"VE1" on a pass, else null.
statsRound-level detail (rounds_answered, rounds_correct, misses, skips, policy) for your logs.
billed_credits / balance_creditsPresent on a billed pass (metered sites): 15 credits (~1.5¢) and your remaining balance.
receipt / tidA tamper-evident login receipt token + id, when receipts are enabled.
Any high-assurance second-channel step (for sensitive sites) happens transparently inside the user’s browser flow — you still just read 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 / errorWhen & response
401 bad_signatureClock skew > 120s, wrong secret, or the body was re-serialised after signing. Fix signing; don’t retry blindly.
402 insufficient_creditMetered balance below one login. Top up at /dash; fall back to password meanwhile.
423 alias_lockedThe person locked this login name. Show the message; they unlock it at VisualEyes.
409 needs_reenrolmentTheir photo pool is too thin. Send them to the portal_url, not a client-side login.
404 unknown_result / already_verifiedResult 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_mismatchThe callback was delivered to a different host than the challenge was minted for. Don’t proxy callbacks across hosts.

8 · Security checklist

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.

Get your credentials → Full API reference → ↓ WordPress plugin Talk to us