← back

Integrate the fraud detection system

How a client product (the customer of our service) wires fraud detection into their signup and login flows.

1. Onboarding (we do this)

  1. Super admin creates the new tenant in /admin/clients with a name and slug.
  2. Optionally create a CLIENT_ADMIN user for them.
  3. Hand the tenant slug and admin credentials to the client.

The client logs into /client-admin, creates an API key under API keys, copies it once (it's hashed in our DB), and gives it to their backend.

2. Client-side: collect signals in the browser

On every signup and login form, run a small JS snippet that captures device fingerprint and behavioural signals. The data is submitted to the client's own backend (never directly to our API from the browser).

signup.htmlhtml
<!-- on the signup / login page -->
<script src="https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@4/dist/fp.umd.min.js"></script>
<script>
const startedAt = Date.now();
const mousePath = [];
const form = document.querySelector('form#signup');

let lastT = 0;
form.addEventListener('mousemove', (e) => {
  const now = performance.now();
  if (now - lastT < 50 || mousePath.length >= 300) return;
  lastT = now;
  mousePath.push({ x: e.clientX, y: e.clientY, t: Math.round(now) });
});

let keystrokeCount = 0;
form.addEventListener('keydown', () => keystrokeCount++);

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const fp = await FingerprintJS.load().then(f => f.get());
  const gl = (() => {
    const c = document.createElement('canvas');
    const g = c.getContext('webgl') || c.getContext('experimental-webgl');
    if (!g) return {};
    const ext = g.getExtension('WEBGL_debug_renderer_info');
    return ext ? {
      vendor: g.getParameter(ext.UNMASKED_VENDOR_WEBGL),
      renderer: g.getParameter(ext.UNMASKED_RENDERER_WEBGL),
    } : {};
  })();
  const payload = {
    email: form.email.value,
    password: form.password.value,
    fraudSignals: {
      device: {
        userAgent: navigator.userAgent,
        screen: { width: screen.width, height: screen.height, pixelRatio: devicePixelRatio },
        timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
        language: navigator.language,
        webglVendor: gl.vendor,
        webglRenderer: gl.renderer,
        hardwareConcurrency: navigator.hardwareConcurrency,
        deviceMemory: navigator.deviceMemory,
        platform: navigator.platform,
        clientFingerprint: fp.visitorId,
      },
      behavior: {
        formStartedAt: startedAt,
        formSubmittedAt: Date.now(),
        mousePath,
        keystrokeCount,
      },
    },
  };
  await fetch('/api/signup', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
});
</script>

3. Server-side: forward to fraud API and act on the score

Before creating the user record, the client's backend calls our ingest endpoint and decides what to do with the returned decision (ALLOW / CHALLENGE / BLOCK).

Node.js (TypeScript)

routes/signup.tsts
import express from 'express';
const router = express.Router();

const FRAUD_API = 'https://fraud.mandet.co/api/v1/events/ingest';
const FRAUD_KEY = process.env.FRAUD_API_KEY!;

router.post('/api/signup', async (req, res) => {
  const { email, password, fraudSignals } = req.body;

  // 1. Run fraud check
  const r = await fetch(FRAUD_API, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': FRAUD_KEY,
    },
    body: JSON.stringify({
      type: 'SIGNUP',
      email,
      externalUserId: email,       // or your own user id
      ip: req.ip,                  // pass real client IP
      device: fraudSignals.device,
      behavior: fraudSignals.behavior,
    }),
  });
  const fraud = await r.json();

  // 2. Branch on the decision
  if (fraud.decision === 'BLOCK') {
    return res.status(403).json({ error: 'Signup blocked', reason: 'risk' });
  }
  if (fraud.decision === 'CHALLENGE') {
    // Stash partial signup, send email/SMS code, require completion later
    await sendVerificationEmail(email);
    return res.json({ challenge: 'email_verification', eventId: fraud.eventId });
  }
  // ALLOW
  const user = await createUser({ email, password });
  return res.json({ user, eventId: fraud.eventId });
});

Python (Flask)

signup.pypython
import os, requests
from flask import request, jsonify

FRAUD_API = 'https://fraud.mandet.co/api/v1/events/ingest'
FRAUD_KEY = os.environ['FRAUD_API_KEY']

@app.post('/api/signup')
def signup():
    body = request.json
    fraud = requests.post(FRAUD_API, json={
        'type': 'SIGNUP',
        'email': body['email'],
        'externalUserId': body['email'],
        'ip': request.remote_addr,
        'device': body['fraudSignals']['device'],
        'behavior': body['fraudSignals']['behavior'],
    }, headers={'x-api-key': FRAUD_KEY}).json()

    if fraud['decision'] == 'BLOCK':
        return jsonify(error='blocked'), 403
    if fraud['decision'] == 'CHALLENGE':
        send_verification_email(body['email'])
        return jsonify(challenge='email_verification', eventId=fraud['eventId'])

    user = create_user(body['email'], body['password'])
    return jsonify(user=user, eventId=fraud['eventId'])

4. What the response contains

ingest responsejson
{
  "eventId": "cmp43...",
  "riskScore": 85,
  "riskBand": "MONITOR",          // ALLOW | MONITOR | BLOCK
  "decision": "CHALLENGE",        // ALLOW | CHALLENGE | BLOCK
  "triggeredRules": [
    { "code": "DEVICE_REUSED",      "label": "Device reused across accounts", "weight": 60 },
    { "code": "DISPOSABLE_EMAIL",   "label": "Disposable email",              "weight": 30 }
  ],
  "allChecks":   [ /* every rule evaluated, FIRED | PASS | DISABLED */ ],
  "derivedSignals": { /* IP geo, device, behaviour, linkage counts, … */ }
}

Recommended branching: ALLOW proceed silently; CHALLENGE add an email/SMS code or KYC step before granting access; BLOCK refuse and surface a generic error (don't reveal the fraud signal).

5. Tuning rules per client

Each tenant has its own rule weights and thresholds. The client's admin tunes them in /client-admin/rules:

Global rule defaults (set by us) live at /admin/global-rules. Reputation lists (disposable email domains, high-risk ASNs, TOR exits) at /admin/reputation.

6. Security checklist