vpn-leak-score
How identifying is your VPN exit, really?
A privacy-tool you actually open: visit through your VPN, hit Run scan, and we run five real measurements against your live connection — IP intelligence, public VPN exit-list cross-check, WebRTC STUN probe, DNS resolver heuristic, browser fingerprint — and produce a single 0–100 Identifiability Score plus a shareable card.
No affiliate links, no "premium tier," no auth. The score, methodology, and aggregate leaderboard are all derived from real data fetched from real public sources at runtime.
What it measures
| Axis | What it tests | Source |
|------|---------------|--------|
| IP pool | Is your exit a major shared VPN (low identifiability), a datacenter, or a consumer ISP? | Cross-check exit IP against mullvad_relays + nordvpn_servers tables, plus IP intelligence flags. |
| ASN entropy | How common your AS number is among the recent scan corpus. | Local scans table over the last 30 days. |
| WebRTC | Client-side STUN candidate gathering — any IP that isn't your exit is a leak. | stun:stun.l.google.com:19302 (client-side). |
| DNS resolver | Heuristic: if the resolver that fetched our unique /api/dns-probe/:nonce URL sits on a different ASN than your exit, your DNS likely isn't tunnelled. Not a true wildcard-DNS test — labelled as such in the UI. | Self-hosted nonce endpoint + IP intel on the resolver. |
| Fingerprint | Canvas 2D render + AudioContext + UA + screen + timezone + font probe → SHA-256. Rarity is measured against the corpus of past visitors. | Local fingerprints table. |
Five axes × 20 points = 100. Higher score = more identifiable. Unknown axes are dropped and the rest is renormalized.
Real data sources
| Name | URL | Refresh | On failure |
|------|-----|---------|-----------|
| IPinfo Lite (if IPINFO_TOKEN is set) | https://api.ipinfo.io/lite/{ip} | per scan, 1h in-memory LRU | falls back to ip-api.com |
| ip-api.com (free fallback) | http://ip-api.com/json/{ip} | per scan, 1h LRU | axis marked unknown |
| Mullvad relay list | https://api.mullvad.net/www/relays/all/ | daily 04:00 UTC + on cold boot if empty/>2d stale | stale rows kept; fetch_log table records failure |
| NordVPN server list | https://api.nordvpn.com/v1/servers?limit=8000 | daily 04:15 UTC, opportunistic | silent skip; leaderboard simply omits Nord |
| WebRTC STUN | stun:stun.l.google.com:19302 | per page load (client-side) | 3s timeout → axis marked unknown |
| DNS resolver probe | self-hosted /vpn-leak-score/api/dns-probe/:nonce | per scan | server records no hit → axis marked unknown |
| Fingerprint corpus | local SQLite fingerprints table | per scan (read/write) | always available |
There are no seed scores, no fake providers, no Math.random() anywhere. If a source is empty or fails, the corresponding axis is dropped and renormalized — never substituted with a placeholder.
Endpoints (all mounted under /vpn-leak-score)
| Method | Path | Purpose |
|--------|------|---------|
| GET | /health | {ok:true} HTTP 200, no auth |
| GET | / | Landing SPA |
| GET | /leaderboard | Provider leaderboard HTML |
| GET | /api/scan | Server-side step 1: returns {ip, asn, asn_name, country, org, is_vpn, vpn_match, dns_probe_nonce} |
| GET | /api/dns-probe/:nonce | Client (or its DNS resolver) hits this; we log the source IP keyed by nonce. Returns a 1×1 GIF. |
| POST | /api/submit | Body: {nonce, webrtc_candidates, fingerprint_hash, fingerprint_components}. Returns {score, verdict, axes, share_token, percentile, ...}. Rate-limited to 60/min per IP. |
| GET | /api/leaderboard | JSON: [{provider, scans, avg_score, p50, p90, updated_at}] over last 7 days. |
| GET | /s/:token | Share page (HTML with OG/Twitter meta tags) |
| GET | /s/:token/card.svg | 1200×630 SVG share card |
Privacy
- Raw IPs are never stored at rest. We keep
sha256(ip || daily_rotated_salt). - Fingerprint hashes are stored (they're already opaque to humans) so we can measure rarity.
- Retention: scans are pruned at 30 days; DNS-probe nonces at 1 hour.
- No cookies, no analytics, no third-party scripts.
Running locally
npm install
PORT=4784 node server.js
# open http://localhost:4784/vpn-leak-score/
Optional: set IPINFO_TOKEN to use IPinfo Lite instead of the free ip-api.com fallback.
cp .env.example .env
# edit IPINFO_TOKEN
When you hit /api/scan from localhost, the server detects the loopback and resolves its own public egress IP via api.ipify.org so you still get meaningful intel.
Architecture
server.js Express + helmet + compression, mounts /vpn-leak-score
db.js better-sqlite3 (WAL), schema bootstrap, prepared statements, daily IP-hash salt
cron.js node-cron schedules: Mullvad daily 04:00 UTC, Nord 04:15 UTC, leaderboard hourly, retention prune daily
routes/
scan.js GET /api/scan, GET /api/dns-probe/:nonce, POST /api/submit (rate-limited)
share.js GET /s/:token, GET /s/:token/card.svg (with OG meta tags)
leaderboard.js GET /api/leaderboard, recomputeLeaderboard()
scrapers/
mullvad.js Fetch + upsert Mullvad relays into mullvad_relays
nordvpn.js Fetch + upsert NordVPN servers (opportunistic)
lib/
ipinfo.js IPinfo Lite (with token) + ip-api.com fallback, 1h LRU
score.js 5-axis scoring with renormalization, verdict bands, percentile
fingerprint.js Upsert + count fingerprint hashes
share-card.js Hand-rolled 1200×630 SVG (no canvas deps)
client-ip.js Real-IP extraction, loopback → ipify egress
public/ Vanilla SPA: index.html, app.js, leaderboard.html, leaderboard.js, style.css
Definition of done
The spec ships with 10 curl-based checks. All ten pass on a fresh boot:
# 1. Health
curl -fsS http://localhost:4784/vpn-leak-score/health
# 2. Server scan
curl -fsS http://localhost:4784/vpn-leak-score/api/scan
# 3. Mullvad relays
sqlite3 data.db "SELECT COUNT(*) FROM mullvad_relays WHERE active=1;"
# … (see SPEC.md §7 for the full set)
What is intentionally out of scope
- No authentication, payments, affiliate links, user accounts.
- No actual VPN provisioning or recommendation engine.
- No true wildcard-DNS leak test (would require DNS-server infra) — only the resolver-ASN heuristic.
- No ProtonVPN / Surfshark / PIA scrapers (no clean public source identified).
- Anything not in
SPEC.md.
License
Internal RNDLAB build. Methodology and source layout are open by design — there is no auth or paywall on any endpoint.