Minecraft anti-VPN: block VPN, proxy and Tor players on Paper for free
Turn away Tor, VPN, proxy and data center players before they join your Paper server, with a tested two-class plugin and FFraud's free IP API.
To keep VPN users off a Minecraft server, check each player's address in AsyncPlayerPreLoginEvent, before they join. Turn away Tor, VPN, proxy and data center addresses, and let everyone else in. The Paper plugin below does that in two classes with FFraud's free IP API, which needs no key and has no daily cap. In our test it decided a new address in a median of 160 ms, and a returning one in 0.34 ms.
#How does the plugin check an address?
package com.example.antivpn;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Asks FFraud's free IP API about a player's address. No key and no libraries. */
public final class IpGuard {
private static final String API = "https://api.ffraud.com/public/ip/";
private static final long KEEP_MS = 5 * 60 * 1000;
private record Answer(long at, String reason) {}
private final String api;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
private final Map<String, Answer> cache = new ConcurrentHashMap<>();
public IpGuard() {
this(API);
}
IpGuard(String api) {
this.api = api;
}
/** Why to turn this address away, or null to let the player in, also when the API cannot answer. */
public String refuse(String ip) {
Answer hit = cache.get(ip);
if (hit != null && System.currentTimeMillis() - hit.at() < KEEP_MS) {
return hit.reason();
}
String body;
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(api + ip)).timeout(Duration.ofSeconds(2)).build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
return null;
}
body = response.body();
} catch (Exception e) {
return null; // the API is down or slow: let the player in
}
String reason = reason(topLevel(body));
if (cache.size() > 10_000) {
cache.clear();
}
cache.put(ip, new Answer(System.currentTimeMillis(), reason));
return reason;
}
/** Tor, VPN, proxy and data center addresses are turned away; every other address is let in. */
static String reason(Map<String, String> a) {
if ("true".equals(a.get("tor"))) return "Tor";
if ("true".equals(a.get("vpn"))) return a.containsKey("vpn_provider") ? "VPN (" + a.get("vpn_provider") + ")" : "VPN";
if ("true".equals(a.get("proxy"))) return "proxy";
if ("true".equals(a.get("hosting"))) {
return a.containsKey("cloud_provider") ? "data center (" + a.get("cloud_provider") + ")" : "data center";
}
return null;
}
/** The top-level fields of a JSON object as text, strings without their quotes; nested objects are skipped. */
static Map<String, String> topLevel(String json) {
Map<String, String> fields = new HashMap<>();
String key = null;
int depth = 0;
for (int i = 0; i < json.length(); i++) {
char c = json.charAt(i);
if (c == '"') {
StringBuilder s = new StringBuilder();
for (i++; i < json.length() && json.charAt(i) != '"'; i++) {
if (json.charAt(i) == '\\') i++;
s.append(json.charAt(i));
}
if (depth == 1 && key == null) key = s.toString();
else if (depth == 1) { fields.put(key, s.toString()); key = null; }
} else if (c == '{' || c == '[') {
depth++;
if (depth == 2) key = null; // a nested value: skipped
} else if (c == '}' || c == ']') {
depth--;
} else if (depth == 1 && key != null && c != ':' && c != ',' && !Character.isWhitespace(c)) {
int end = i;
while (end < json.length() && ",}".indexOf(json.charAt(end)) < 0) end++;
String value = json.substring(i, end).trim();
if (!value.equals("null")) fields.put(key, value);
key = null;
i = end - 1;
}
}
return fields;
}
}- One call, 2-second limits.
refuseasks/public/ip/{ip}with Java's built-inHttpClient, with a 2-second limit on the connection and on the answer. - Fail open. An error, a timeout or any status but 200 returns null, and the player gets in. The plugin must decide at once, so failing closed would lock every player out while the API is unreachable.
- A 5-minute cache. A player who reconnects within 5 minutes gets the stored answer in under a millisecond. The API marks its answers cacheable for as long (
Cache-Control: max-age=300, October 26, 2026). - Flags decide.
reasonturns away an address flaggedtor,vpnorproxy, and anyhostingaddress, one that "belongs to a datacenter / cloud range" (FFraud docs). The reason goes to the server log. - No libraries.
topLevelreads only the top-level fields of the answer, so the jar needs nothing beyond Paper.
Why every data center address? A login offers only yes or no, with no CAPTCHA step for a doubtful player, so the plugin acts on the flags. Apple's iCloud Private Relay runs from data centers too, and its addresses get turned away. That costs no players: Private Relay carries "web browsing in Safari, DNS resolution queries, and insecure http app traffic" (Apple), and a Minecraft connection is none of these.
#How do you hook it into Paper?
package com.example.antivpn;
import java.net.InetAddress;
import net.kyori.adventure.text.Component;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.plugin.java.JavaPlugin;
/** Turns away players who connect through Tor, a VPN, a proxy or a data center, with FFraud's free IP API. */
public final class AntiVpnPlugin extends JavaPlugin implements Listener {
private final IpGuard guard = new IpGuard();
@Override
public void onEnable() {
getServer().getPluginManager().registerEvents(this, this);
}
// The event runs off the main thread, so waiting for the API does not stall the server.
@EventHandler
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
InetAddress address = event.getAddress();
String ip = address.getHostAddress();
if (address.isLoopbackAddress() || address.isSiteLocalAddress()) {
// A player on your own network, or a proxy in front of the server that does not pass on addresses.
getLogger().warning(event.getName() + " joins from the local address " + ip + ", which is not checked");
return;
}
String reason = guard.refuse(ip);
if (reason != null) {
getLogger().info("Turned away " + event.getName() + " from " + ip + ": " + reason);
event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
Component.text("VPN, proxy and Tor connections are not allowed on this server."));
}
}
}name: AntiVpnCheck
version: 1.0.0
main: com.example.antivpn.AntiVpnPlugin
api-version: '1.21'
description: Turns away players on Tor, VPNs, proxies and data center addresses, with FFraud's free IP API.- The event.
AsyncPlayerPreLoginEvent"is asynchronous, and not run using main thread", and it fires "after the server has successfully completed Mojang authentication" (Paper javadoc). Waiting for the API holds up only that one login. - The kick.
disallowwith aComponentrefuses the login with your message. The overload that takes a String is deprecated (Paper javadoc). - Local addresses. Loopback and private addresses such as
127.0.0.1and192.168.1.20get no flag from the API anyway. The plugin skips the call, lets them in and writes a warning to the log. - `plugin.yml`.
name,versionandmainare required.api-version: '1.21'makes servers older than 1.21 refuse to load the plugin; Paper accepts 1.13 to 26.3 there (Paper docs).
Put both classes in src/main/java/com/example/antivpn/ and plugin.yml in src/main/resources/. Paper's project setup guide adds paper-api from Paper's Maven repository as a compileOnly dependency; pick the version your server runs. Then drop the jar into the server's plugins folder and restart (Paper docs). To let one trusted player in, compare event.getUniqueId() with your own list before the lookup.
#What if a proxy sits in front of the server?
Behind Velocity, BungeeCord or a DDoS shield, the connection to your server comes from the proxy. Until the proxy passes on each player's own address, the plugin checks the proxy's:
- Velocity: set
player-info-forwarding-modetomoderninvelocity.toml. Inconfig/paper-global.yml, setproxies.velocity.enabledtotrueandproxies.velocity.secretto the proxy's secret (Velocity docs). - BungeeCord, or Velocity in legacy mode: set
settings.bungeecordtotrueinspigot.yml. The Velocity docs call this legacy forwarding "fundamentally insecure", so let only the proxy reach your server. - TCPShield: install its RealIP plugin, which "parses passed IP addresses so the server is aware of the real player IP address" (TCPShield).
- HAProxy or a host with PROXY protocol: set
proxies.proxy-protocoltotrueinconfig/paper-global.yml(Paper docs).
Forwarding that is off shows in one of two ways. A proxy on the same machine or network makes every player arrive from a local address, and the plugin's warning names it. A proxy in a data center gets every player turned away as "data center", with the proxy's address in the log. Join once after you install the plugin and read the log.
#Which players does it turn away?
We drew 20 addresses from each of four public lists on October 26, 2026, and applied the plugin's rule to the free API's answers:
| List | Turned away | Reason in the log |
|---|---|---|
| Tor exits | 20 of 20 | Tor |
| PIA VPN servers | 20 of 20 | VPN (Private Internet Access) |
| AWS EC2 | 20 of 20 | data center (Amazon Web Services) 19, VPN 1 |
| iCloud Private Relay | 20 of 20 | data center 19, VPN 1 |
We had no public list of home or mobile addresses to sample, so the table shows only what the plugin turns away. A home or mobile player gets in unless the API flags the address as Tor, a VPN, a proxy or a data center.
#What does a login wait for?
We timed the listener and a second, fresh client on Java 17, one API call a second at most, from our test machine in Germany:
- API unreachable, player let in2,007 ms
- First check after the start643 ms
- New address, slowest of 19506 ms
- New address, median of 19160 ms
- Same address within 5 minutes0.34 ms
Source: FFraud test of the plugin on Java 17, October 26, 2026
The first check after the start took 643 ms, and the second client's first call 148 ms. After that, a new address cost a median of 160 ms, and a returning player waited 0.34 ms. Your own times depend on your distance to the API.
#What do ready-made plugins ask for?
Four plugin pages, read on October 26, 2026, name the service each one checks with and its free allowance:
| Plugin, where listed | Checks with | Free allowance, as its page states |
|---|---|---|
| Anti VPN (Modrinth) | its own cloud checker, with a token from its website | 5,000 requests a month |
| KauriVPN (Hangar) | its own API | 20,000 queries, then a license |
| ProxyTracer AntiVPN (Hangar) | its own API, with a key | "free checks every month", no number |
| FoxGate (Modrinth) | services you choose, several on by default | set by each service; its own allows 150 requests a minute without a key |
| The plugin above | FFraud's free IP API, no key | no daily or monthly cap |
FoxGate also takes services you add. Its wiki describes one as a URL with an {IP} placeholder and a list of answer fields to check. FFraud's endpoint fits that shape: https://api.ffraud.com/public/ip/{IP}, with the true-or-false fields tor, vpn, proxy and hosting. We did not run FoxGate itself.
#How did we test it?
We compiled the plugin with Java 17 and ran its listener on October 26, 2026. Paper was not installed: small stand-ins supplied the Paper and Adventure classes the plugin imports. Every lookup went to the live API, one call a second at most:
Java 17.0.17, 2026-10-26T02:18:32.139923600Z
listener registered: true
[INFO] Turned away Steve from 45.66.35.28: Tor
45.66.35.28 Tor exit -> KICK_OTHER "VPN, proxy and Tor connections are not allowed on this server." 643 ms
[INFO] Turned away Steve from 62.169.130.76: VPN (Private Internet Access)
62.169.130.76 PIA VPN server -> KICK_OTHER "VPN, proxy and Tor connections are not allowed on this server." 206 ms
[INFO] Turned away Steve from 15.193.8.246: data center (Amazon Web Services)
15.193.8.246 AWS EC2 -> KICK_OTHER "VPN, proxy and Tor connections are not allowed on this server." 113 ms
[INFO] Turned away Steve from 104.28.80.101: data center (Cloudflare)
104.28.80.101 iCloud Private Relay -> KICK_OTHER "VPN, proxy and Tor connections are not allowed on this server." 135 ms
[INFO] Turned away Steve from 8.8.8.8: data center (Google Cloud)
8.8.8.8 Google DNS -> KICK_OTHER "VPN, proxy and Tor connections are not allowed on this server." 147 ms
[WARNING] Steve joins from the local address 127.0.0.1, which is not checked
127.0.0.1 proxy on this machine -> ALLOWED 0 ms
[WARNING] Steve joins from the local address 192.168.1.20, which is not checked
192.168.1.20 player on the same network -> ALLOWED 0 ms
[INFO] Turned away Alex from 45.66.35.28: Tor
45.66.35.28 again within 5 minutes -> KICK_OTHER in 0.34 ms
API down, 45.66.35.28 -> let in after 2007 ms
192.42.116.95 -> turned away: Tor
192.42.116.103 -> turned away: Tor
185.220.101.175 -> turned away: Tor
23.129.64.171 -> turned away: Tor
204.8.96.157 -> turned away: Tor
172.226.36.122 -> turned away: data center
104.28.133.112 -> turned away: data center (Cloudflare)
172.226.175.240 -> turned away: data center
104.28.79.124 -> turned away: data center (Cloudflare)
172.226.87.148 -> turned away: VPN
147.90.173.128 -> turned away: VPN (Private Internet Access)
82.139.195.103 -> turned away: VPN (Private Internet Access)
196.245.54.145 -> turned away: VPN (Private Internet Access)
158.173.3.23 -> turned away: VPN (Private Internet Access)
158.173.165.19 -> turned away: VPN (Private Internet Access)
136.18.254.148 -> turned away: data center (Amazon Web Services)
16.214.26.119 -> turned away: data center (Amazon Web Services)
3.5.240.48 -> turned away: data center (Amazon Web Services)
15.177.73.181 -> turned away: data center (Amazon Web Services)
15.177.81.139 -> turned away: VPN
first lookup, with a new connection: 148 ms
next 19 lookups: median 160 ms, fastest 114 ms, slowest 506 msWe did not see the kick screen in a real client, so try the plugin on a test server first.
#Questions people ask
How do I block VPNs on my Minecraft server?
Check each player's address before they join and refuse Tor, VPN, proxy and data center addresses. The plugin above does it in AsyncPlayerPreLoginEvent with FFraud's free IP API.
Does prevent-proxy-connections block VPNs?
It refuses a player when the ISP or AS your server sees differs from the one Mojang's authentication server saw (Paper docs). A VPN that carries all of the player's traffic sends both through the same network, so the two match and the player gets in. To see the ISP and AS of your own connection, use
Why does every player show up as 127.0.0.1?
A proxy on the same machine is passing on no addresses. Turn on Velocity's modern forwarding, or your proxy's equivalent, as shown above.
Will the plugin turn away players behind TCPShield?
It judges TCPShield's addresses instead of the players' own, unless TCPShield's RealIP plugin passes the real addresses to your server. Install RealIP first.
What happens when the API is down?
The player gets in. In our test, a login waited 2,007 ms for an API that did not answer and was then let through.
Is the API free for a busy server?
Yes. The public endpoint needs no key and has no daily or monthly caps. Its only limit is a burst of hundreds of requests per second per address (FFraud docs), and the cache asks about a returning player at most once every 5 minutes.
To look up one address by hand, use the free
Sources
IP intelligence Rate limits & fair use AsyncPlayerPreLoginEvent plugin.yml Project setup Adding plugins server.properties Global configuration Configuring player information forwarding RealIP plugin Prepare your network or web server for iCloud Private Relay Tor bulk exit list Server list, version 6 AWS IP address ranges