Live: Tor + abuse feeds refreshed every 6 hours

LayerCall

Django

Backend

What exists today

requests.post from wherever you already validate the form. The Django-specific trap is the IP: REMOTE_ADDR is your load balancer's address on almost every production deployment, not the visitor's.

Setup

  1. 1Put the key in an environment variable and read it with os.environ, not in settings.py.
  2. 2Resolve the client IP correctly — see the snippet, and read the warning on X-Forwarded-For.
  3. 3POST to https://www.layercall.com/v1/score/user with an explicit timeout.
  4. 4Raise ValidationError on block so the form redisplays with the message.
  5. 5Do it in the form's clean() rather than the view, so every code path that uses the form is covered.

Code

import os
import requests
from django.core.exceptions import ValidationError

ENDPOINT = "https://www.layercall.com/v1/score/user"


def client_ip(request):
    """
    Only trust X-Forwarded-For if you terminate TLS behind a proxy you
    control. If anything can reach Django directly, a client can set this
    header to whatever it likes and pick its own reputation.
    """
    xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
    if xff and getattr(settings, "TRUST_PROXY", False):
        return xff.split(",")[0].strip()
    return request.META.get("REMOTE_ADDR", "")


class SignupForm(forms.Form):
    email = forms.EmailField()

    def __init__(self, *args, request=None, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

    def clean(self):
        cleaned = super().clean()
        try:
            r = requests.post(
                ENDPOINT,
                headers={"X-Api-Key": os.environ["LAYERCALL_KEY"]},
                json={
                    "ip": client_ip(self.request),
                    "email": cleaned.get("email"),
                },
                timeout=5,
            )
            r.raise_for_status()
            scored = r.json()
        except requests.RequestException:
            return cleaned  # fail open

        if scored["verdict"] == "block":
            raise ValidationError(scored["summary"])

        cleaned["risk_verdict"] = scored["verdict"]
        return cleaned

What to do with each verdict

Three outcomes, and the middle one is the one worth getting right — refusing a real customer usually costs more than reviewing them.

VerdictIn Django
allowReturn cleaned data and let the view save.
reviewSave with is_active=False, or a risk_verdict column, and require email confirmation before the account can do anything.
blockraise ValidationError(scored['summary']). The summary is written for a person to read, so it renders straight into the form's error list.

What comes back

A real response, generated from the live API rather than written by hand. Branch on verdict; summary is a sentence written to be shown to a person, and components_checked tells you what actually went into the score.

Show the full response(POST /v1/score/user)
{
  "risk_score": 65,
  "verdict": "review",
  "summary": "Needs review (65/100) — Tor exit node, commercial VPN and datacenter ASN.",
  "components": {
    "email": {
      "email": "test@guerrillamail.com",
      "normalized_email": "test@guerrillamail.com",
      "risk_score": 100,
      "verdict": "block",
      "status": "do_not_mail",
      "sub_status": "disposable",
      "deliverability_score": 0,
      "did_you_mean": null,
      "signals": {
        "syntax_valid": true,
        "mx_found": true,
        "is_disposable": true,
        "is_homograph": false,
        "is_role_account": true,
        "is_free_provider": false,
        "is_suspicious_handle": true,
        "is_tagged": false,
        "is_risky_tld": false,
        "is_new_domain": null,
        "has_spf": true,
        "has_dmarc": true,
        "has_website": true,
        "mailbox_exists": null,
        "is_catch_all": null,
        "mailbox_status": "unavailable",
        "has_digital_footprint": null
      },
      "domain": "guerrillamail.com",
      "domain_age_days": null,
      "mx_provider": null,
      "mx_records": [
        "mail.guerrillamail.com."
      ],
      "abuse_reports": 0,
      "digital_footprint": {
        "has_gravatar": false,
        "gravatar_profile_url": null,
        "breach_count": null,
        "seen_in_breach": null
      }
    },
    "phone": {
      "parse_status": "ok",
      "phone": "+14155552671",
      "risk_score": 0,
      "signals": {
        "syntax_valid": true,
        "is_possible": true,
        "is_voip": false,
        "is_premium_rate": false,
        "is_toll_free": false,
        "assigned_area_code": true,
        "is_fictional": false
      },
      "number": {
        "e164": "+14155552671",
        "country": "US",
        "national": "(415) 555-2671",
        "international": "+1 415 555 2671",
        "line_type": "fixed_line_or_mobile"
      },
      "verdict": "allow",
      "abuse_reports": 0
    },
    "ip": {
      "ip": "185.220.101.1",
      "risk_score": 60,
      "signals": {
        "is_vpn": true,
        "is_proxy": true,
        "is_datacenter": true,
        "is_tor": true,
        "recent_abuse": false,
        "is_hijacked_netblock": false
      },
      "geo": {
        "country": "DE",
        "city": "Berlin",
        "asn": "AS60729",
        "isp": "Stiftung Erneuerbare Freiheit"
      },
      "vpn_provider": null,
      "hijacked_source": null,
      "verdict": "review",
      "abuse_reports": 0
    }
  },
  "components_checked": [
    "email",
    "phone",
    "ip"
  ],
  "linkage": {
    "device_email_count": null,
    "email_device_count": 0,
    "email_ip_count": 0,
    "subnet_rate_1h": 2,
    "domain_rate_1h": 0
  },
  "actor": {
    "type": "unknown",
    "proven": false,
    "basis": "none",
    "operator": null,
    "trigger": null,
    "detail": "No signature and no device fingerprint. Drop fp.js on the page, or pass the agent's signed request, to get an answer here."
  },
  "top_signals": [
    "ip: tor exit node",
    "ip: commercial vpn",
    "ip: datacenter asn",
    "email: disposable domain",
    "email: role account",
    "email: machine-generated handle"
  ]
}

Traps specific to Django

REMOTE_ADDR is your load balancer

Behind nginx, ELB, Cloudflare or Heroku's router, REMOTE_ADDR is the proxy. Every visitor then scores against the same IP — usually a datacenter one, which is exactly the profile that raises risk. You will see uniformly elevated scores and conclude the API is broken.

X-Forwarded-For is attacker-controlled unless a proxy overwrites it

Trusting it unconditionally is worse than using REMOTE_ADDR, because it lets a client choose its own IP. Gate it behind an explicit setting as above, and set that setting only where a proxy you control is guaranteed to be in front.

requests has no default timeout

Omit timeout= and a slow response hangs the worker until the upstream gives up, which on a synchronous WSGI deployment means one fewer worker for the duration. Always pass it.

Questions

View, form, or signal?

The form's clean(). A view covers one code path; the form covers every path that uses it, including the admin and any API serializer built on it. A post_save signal is too late — the row already exists.

Will this slow down my signup page?

By roughly the round trip, around 300ms. If that matters more than blocking the signup, move it to a Celery task and score after the fact — the signal is just as valid a second later, you just have to undo rather than refuse.