VPN detection API that names the VPN when it’s known

Our script runs on your signup or login page, your server sends the visit’s token to /v1/evaluate, and you get back allow, review or block, with the VPN service in network.service when it’s known.

  • Free plan: 10,000 visitor checks a month
  • No card
  • Paid plans from €29 a month
Example checkA visit through a VPN
Reviewrisk_score 65
IP address
198.51.100.18
VPN
Proton VPN
Proxy
Not detected
Tor exit
Not detected
Cloud server
Detected
Country
Netherlands
"decision": "review""reasons": ["vpn_detected", "datacenter_asn"]
The public sample for a VPN visit, as your server gets it. See the JSON

What a check tells your server

decision
Allow, review or block. A VPN on its own is review. A proxy, a Tor exit or an automated or fake browser is block; Apple’s Private Relay is review. A cloud server on its own is allow.
network.vpn
True when the visit comes through a VPN the check knows. network.proxy, network.tor and network.datacenter sit beside it.
network.service
The VPN service, such as PROTON_VPN, when it’s known, and null when it isn’t.
reasons
Short codes such as vpn_detected and datacenter_asn to log and route on.
risk_score
0 to 100, for sorting and thresholds. It isn’t the probability that someone is a fraudster.

You can change any default per signal under Rules in the dashboard. A rule changes the answer only for the signal it names, so allowing VPNs never lets a proxy or a fake browser through.

Add it in front of your login

Add <script async src="https://maskbreak.com/assets/sentinel.js"></script> to the page and the class monocle-enriched to the form: the form then carries the visit’s token (monocle), its browser check (sentinel_fp) and its time zone (sentinel_tz). Your server sends them to /v1/evaluate before your own login handler runs. The Python and PHP examples use the official SDKs.

On your server
Node.js
// Server-side only (Node 18+, Express). Runs before your existing login handler.
// 'watch' (default): log what Maskbreak would do, always continue.
// 'enforce': stop block; hold review, missing evidence and errors
// (a used-up month excepted: logged, then the login continues).
const MASKBREAK_MODE = process.env.MASKBREAK_MODE || 'watch';

app.post('/login', async (req, res, next) => {
  const token = req.body.token || req.body.monocle;
  const fingerprintEventId = req.body.fingerprintEventId || req.body.sentinel_fp;
  let result = null, hold = null, monthUsedUp = false;

  if (!token) {
    hold = [409, 'Verification required.'];
  } else {
    try {
      const response = await fetch('https://maskbreak.com/v1/evaluate', {
        method: 'POST',
        signal: AbortSignal.timeout(5000),
        headers: {
          Authorization: 'Bearer ' + process.env.MASKBREAK_API_KEY,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ token, fingerprintEventId, tz: req.body.tz || req.body.sentinel_tz })
      });
      if (!response.ok) {
        const refusal = await response.json().catch(() => null);
        // The month's checks are used up: let the login through, logged, until the 1st.
        if (response.status === 429 && refusal?.code === 'monthly_quota_exceeded') monthUsedUp = true;
        else throw new Error('Evaluation unavailable');
      } else {
        result = await response.json();
        if (!['allow', 'review', 'block'].includes(result.decision) ||
            result.test || result.sample || result.sandbox) throw new Error('Not a live verdict');

        if (result.decision === 'block') hold = [403, 'Request declined.'];
        else if (result.decision === 'review' || result.degraded) {
          hold = [409, 'Verification required.']; // e.g. send an email code
        }
      }
    } catch {
      hold = [503, 'Verification unavailable. Try again.'];
    }
  }

  console.log('[maskbreak]', JSON.stringify({
    mode: MASKBREAK_MODE, decision: result?.decision ?? null, reasons: result?.reasons ?? null,
    enforce_would: hold ? 'return ' + hold[0] : monthUsedUp ? 'continue (month used up)' : 'continue'
  }));
  if (MASKBREAK_MODE === 'enforce' && hold) return res.status(hold[0]).json({ error: hold[1] });
  return next(); // watch mode, or allow: your normal login runs
}, existingLoginHandler);
Python
# pip install sentinelsup   (Flask shown; any framework works)
import json
import os
from flask import request, abort
from sentinel import Sentinel, SentinelError

maskbreak = Sentinel(api_key=os.environ["MASKBREAK_API_KEY"])
MODE = os.environ.get("MASKBREAK_MODE", "watch")  # watch: log only; enforce: act on it

@app.post("/login")
def login():
    result, hold = None, None
    try:
        result = maskbreak.evaluate(
            token=request.form.get("monocle", ""),
            fingerprint_event_id=request.form.get("sentinel_fp"),
        )
        if result.test:
            hold = 400  # a test token must never pass a live action
        elif result.decision == "block":
            hold = 403
        elif result.decision == "review" or result.raw.get("degraded"):
            hold = "verify"
    except SentinelError as e:  # missing token, timeout or API error
        # The month's checks are used up: let the login through, logged, until the 1st.
        if e.status == 429 and e.body.get("code") == "monthly_quota_exceeded":
            hold = None
        else:
            hold = 503

    print("[maskbreak]", json.dumps({  # one line per check, whatever your log level
        "mode": MODE, "decision": getattr(result, "decision", None),
        "reasons": getattr(result, "reasons", None), "enforce_would": hold or "continue",
    }), flush=True)
    if MODE == "enforce" and hold == "verify":
        return send_email_code(request.form["email"])  # your extra step
    if MODE == "enforce" and hold:
        abort(hold)
    return existing_login()  # watch mode, or allow: your normal login runs
PHP
<?php
// composer require sentinelsup/sdk
$mode = getenv('MASKBREAK_MODE') ?: 'watch'; // watch: log only; enforce: act on it
$maskbreak = new \Sentinel\Client(getenv('MASKBREAK_API_KEY') ?: null);
$result = null;
$hold = null;

try {
    $result = $maskbreak->evaluate([
        'token' => $_POST['monocle'] ?? '',
        'fingerprintEventId' => $_POST['sentinel_fp'] ?? '',
    ]);
    if ($result->test) { $hold = 400; } // never on a live action
    elseif ($result->decision === 'block') { $hold = 403; }
    elseif ($result->decision === 'review' || !empty($result->raw['degraded'])) { $hold = 'verify'; }
} catch (\Sentinel\SentinelException $e) { // missing token, timeout or API error
    // The month's checks are used up: let the login through, logged, until the 1st.
    $monthUsedUp = $e->getStatus() === 429 && ($e->getBody()['code'] ?? null) === 'monthly_quota_exceeded';
    $hold = $monthUsedUp ? null : 503;
}

error_log('[maskbreak] ' . json_encode([
    'mode' => $mode, 'decision' => $result->decision ?? null,
    'reasons' => $result->reasons ?? null, 'enforce_would' => $hold ?? 'continue',
]));
if ($mode === 'enforce' && $hold === 'verify') {
    send_email_code($_POST['email']); // your extra step
    exit;
}
if ($mode === 'enforce' && $hold) { http_response_code($hold); exit; }
// watch mode, or allow: your normal login continues here

Each example starts in watch mode: it logs what it would do and always lets the visitor through. Once the log matches Events in your dashboard, set MASKBREAK_MODE=enforce. In enforce mode a timeout or an error holds the login, never counts as a clean visit. The one exception is a used-up month (HTTP 429 with the code monthly_quota_exceeded): the examples log it and let the login through until the allowance resets on the 1st, in enforce mode too, so running out of checks never locks your users out.

With the public key sk_test_sandbox and no account, the token test_vpn answers review, test_clean allow, and test_proxy and test_tor block, so you can run each path before real traffic. The VPN detection API test cases guide turns them into a test for CI.

What it can’t see

VPNs it doesn’t know yet
A server that came online recently, or a service that sends traffic through home and mobile addresses, may not be known as an exit yet. The flags then stay false.
The name
network.vpn can be true while network.service is null. Base your rules on the flags and treat the name as extra context.
A blocked script
Ad blockers and hardened browsers can stop the script. The answer then says degraded: true: ask for verification instead of counting the visit as clean.

VPN detection API questions

Is the VPN detection API free?

Yes, up to the Free plan: 10,000 visitor checks and 100,000 IP lookups a month, with no card. Paid plans start at €29 a month for 50,000 checks; the pricing page has the rest.

Can you detect a VPN from an IP address alone?

Not with Maskbreak. A bare IP lookup (/v1/lookup and the free IP lookup tool) checks only Tor exits and cloud-server ranges; a VPN verdict needs a live visit, the browser script’s token sent to /v1/evaluate. If all you have is addresses from old logs, the free VPN detection API comparison lists IP-based services.

Should I block visitors who use a VPN?

Not for the VPN alone: plenty of real customers use one at work or on hotel Wi-Fi. A VPN on its own comes back review, and the usual response is an extra step such as an email code; VPN detection without blocking real customers covers how.

How accurate is it?

We haven’t published a detection rate, because we haven’t run a controlled benchmark we’d stand behind. Test it on your own traffic, and count real customers who were flagged as well as VPNs that got through.

Check your signups and logins for VPNs

Setup starts in watch mode, so nobody is blocked until you turn blocking on.