On this page
This tutorial builds an anti-VPN plugin for a Minecraft network in Java. On a Velocity proxy, it looks up each player's address with io.vpndetection:vpndetection, our official Java library, as they log in: Tor exits and residential proxies are refused, and staff are told when someone joins through a VPN. A second version does the same on a single Paper server with no proxy. On the way, it covers the Velocity setting that decides whether a backend server sees the player's address at all. We built and ran both on October 4, 2026, with Velocity 4.2.0, Paper 26.2, Java 25 and version 6.3.3 of the library, and every output below is from those runs.
Add the library
Velocity can't download a plugin's libraries for it, so the library goes inside the plugin's jar. It brings Jackson, Caffeine and Apache HttpClient along, and another plugin on the same proxy may bundle other versions of any of them, so the shade plugin relocates all of them under the plugin's own package:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>vpncheck-velocity</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.velocitypowered</groupId>
<artifactId>velocity-api</artifactId>
<version>4.2.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.vpndetection</groupId>
<artifactId>vpndetection</artifactId>
<version>6.3.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<!-- Velocity's processor writes velocity-plugin.json -->
<proc>full</proc>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.2</version>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>io.vpndetection</pattern>
<shadedPattern>com.example.vpncheck.lib.vpndetection</shadedPattern>
</relocation>
<relocation>
<pattern>com.fasterxml.jackson</pattern>
<shadedPattern>com.example.vpncheck.lib.jackson</shadedPattern>
</relocation>
<relocation>
<pattern>com.github.benmanes.caffeine</pattern>
<shadedPattern>com.example.vpncheck.lib.caffeine</shadedPattern>
</relocation>
<relocation>
<pattern>org.apache</pattern>
<shadedPattern>com.example.vpncheck.lib.apache</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>module-info.class</exclude>
<exclude>META-INF/versions/*/module-info.class</exclude>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>Velocity 4.2 and Paper 26.2 both need Java 25. The <proc>full</proc> line is there because javac stopped running annotation processors by default in JDK 23, and Velocity's processor is what writes velocity-plugin.json from the @Plugin annotation. mvn package builds target/vpncheck-velocity-1.0.0.jar, about 5.5 MB, which goes in Velocity's plugins folder.
Get an API key in the console: a free one covers 50,000 lookups a month. Without a key, the API allows 1,000 lookups a day per source address, and every lookup comes from your proxy's address, so the whole network would share those 1,000. The plugin creates plugins/vpncheck/config.properties on its first start, with an empty key to fill in:
api-key=Check players on the proxy
package com.example.vpncheck;
import com.google.inject.Inject;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.ResultedEvent.ComponentResult;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import io.vpndetection.Result;
import io.vpndetection.VPNDetection;
import java.io.IOException;
import java.io.Reader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Properties;
import java.util.concurrent.CompletionException;
import net.kyori.adventure.text.Component;
import org.slf4j.Logger;
@Plugin(id = "vpncheck", name = "VPNCheck", version = "1.0.0")
public final class VpnCheck {
private static final Component REFUSED =
Component.text("Tor and proxy networks can't join this server.");
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
private VPNDetection client;
@Inject
public VpnCheck(ProxyServer proxy, Logger logger,
@DataDirectory Path dataDirectory) {
this.proxy = proxy;
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialize(ProxyInitializeEvent event) throws IOException {
String apiKey = loadApiKey();
client = VPNDetection.builder()
.apiKey(apiKey.isEmpty() ? null : apiKey)
.cacheTtl(Duration.ofHours(6))
.requestTimeout(Duration.ofSeconds(2))
.retries(0)
.build();
}
@Subscribe
public EventTask onLogin(LoginEvent event) {
Player player = event.getPlayer();
String ip = player.getRemoteAddress().getAddress().getHostAddress();
return EventTask.resumeWhenComplete(client.lookupAsync(ip)
.handle((result, error) -> {
if (error == null) {
apply(event, player, result);
} else {
Throwable cause = error instanceof CompletionException
? error.getCause() : error;
logger.warn("Let {} ({}) in unchecked: {}",
player.getUsername(), ip, cause.getMessage());
}
return null;
}));
}
private void apply(LoginEvent event, Player player, Result result) {
String name = player.getUsername();
if (result.isTor().orElse(false) || result.isResproxy().orElse(false)) {
logger.info("Refused {} ({}): Tor or residential proxy",
name, result.ip());
event.setResult(ComponentResult.denied(REFUSED));
return;
}
if (result.isVpn()) {
String provider = result.vpn()
.map(vpn -> vpn.getProvider()).orElse("unknown");
String note = name + " joined through a VPN (" + provider + ")";
logger.info("{} from {}", note, result.ip());
proxy.getAllPlayers().stream()
.filter(staff -> staff.hasPermission("vpncheck.notify"))
.forEach(staff -> staff.sendMessage(Component.text(note)));
}
}
private String loadApiKey() throws IOException {
Path file = dataDirectory.resolve("config.properties");
if (Files.notExists(file)) {
Files.createDirectories(dataDirectory);
Files.writeString(file, "api-key=\n");
}
Properties config = new Properties();
try (Reader reader = Files.newBufferedReader(file)) {
config.load(reader);
}
return config.getProperty("api-key", "").strip();
}
}LoginEvent fires once the player has been authenticated with Mojang, before they connect to any backend. PreLoginEvent comes earlier, before authentication, so the name it carries is only what the client sent, and that name goes in the log and in the message to staff.
The handler doesn't wait for the API. It hands Velocity an EventTask built from the lookup's future, and Velocity carries on with the login when the future completes. No proxy thread is held while the request is out.
One client serves the whole proxy: it holds the connection pool and the cache. Answers are cached for six hours (the library's default is one), so a player who reconnects costs no lookup. Each lookup is one attempt of at most two seconds, from requestTimeout and retries(0). The library's defaults, 30 seconds an attempt and two retries, suit a batch job better than a login screen.
We joined four players through the proxy:
| Player | Address | Result |
|---|---|---|
| Steve | 45.134.140.130, a Mullvad server in Atlanta from Mullvad's server list | Joined, staff told |
| Alex | a home broadband line | Joined |
| Kai | 198.98.51.189, a Tor exit from the Tor Project's exit list | Refused |
| Noor | a residential proxy exit | Refused |
Kai and Noor were disconnected with Tor and proxy networks can't join this server. The proxy logged:
[vpncheck]: Steve joined through a VPN (mullvad) from 45.134.140.130
[vpncheck]: Refused Kai (198.98.51.189): Tor or residential proxy
Noor got the same Refused line, and Alex got none. We didn't route a Minecraft client through each network: a small test client ran the login handshake from Docker networks set up on each address's own range, so Velocity saw the real address. Velocity ran in offline mode for the test, since the test client has no Minecraft account.
What the backend sees
A plugin on the Paper backends instead of the proxy is a common setup, and behind Velocity it checks the wrong address unless Velocity forwards the player's. The backend's connection comes from Velocity, not from the player. We put the Paper plugin from the next section on the backend, with a key, took the plugin off the proxy, and joined Kai through Velocity:
player-info-forwarding-mode | Address Paper checked | Kai, on a Tor exit |
|---|---|---|
"NONE", Velocity's default | Velocity's own | Joined |
"MODERN" | 198.98.51.189 | Refused |
With "NONE", Velocity's address was a private one on our Docker network, which the library answers locally as reserved, so every player would pass. A proxy on a public address would get its own verdict for every player. With "MODERN", Kai saw Unable to connect to lobby: Tor and proxy networks can't join this server.
Modern forwarding is one line in velocity.toml:
player-info-forwarding-mode = "MODERN"Then, per Paper's forwarding guide, online-mode=false in each backend's server.properties, since the proxy authenticates players, and in its config/paper-global.yml:
proxies:
velocity:
enabled: true
online-mode: true
secret: 'the contents of forwarding.secret'proxies.velocity.online-mode matches online-mode in velocity.toml, and the secret is the one Velocity wrote to forwarding.secret on its first start. With forwarding on, a player who connects to a backend directly, around the proxy, is turned away with This server requires you to connect with Velocity.
Even with forwarding on, check on the proxy. A backend checks again every time Velocity connects a player to it, on each server switch, with its own cache. A refusal there also reads as a failed server connection, where the proxy's is a plain message.
One Paper server, no proxy
On a single Paper server, the check goes on AsyncPlayerPreLoginEvent. Paper fires it on a login thread rather than the main one, so the plugin can call the blocking lookup there without stalling the game. The pom.xml is the one above with its own artifactId, paper-api in place of velocity-api, and no maven-compiler-plugin block, since Paper reads plugin.yml rather than an annotation:
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>26.2.build.129-stable</version>
<scope>provided</scope>
</dependency>src/main/resources/plugin.yml:
name: VPNCheck
version: 1.0.0
main: com.example.vpncheck.VpnCheckPaper
api-version: '26.2'src/main/resources/config.yml, which saveDefaultConfig() copies to plugins/VPNCheck/config.yml for the key:
api-key: ""And the plugin:
package com.example.vpncheck;
import io.vpndetection.Result;
import io.vpndetection.VPNDetection;
import io.vpndetection.VPNDetectionException;
import java.time.Duration;
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;
public final class VpnCheckPaper extends JavaPlugin implements Listener {
private static final Component REFUSED =
Component.text("Tor and proxy networks can't join this server.");
private VPNDetection client;
@Override
public void onEnable() {
saveDefaultConfig();
String apiKey = getConfig().getString("api-key", "").strip();
client = VPNDetection.builder()
.apiKey(apiKey.isEmpty() ? null : apiKey)
.cacheTtl(Duration.ofHours(6))
.requestTimeout(Duration.ofSeconds(2))
.retries(0)
.build();
getServer().getPluginManager().registerEvents(this, this);
}
// Paper calls this on a login thread, not the main thread, so it may block.
@EventHandler
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
String name = event.getName();
String ip = event.getAddress().getHostAddress();
Result result;
try {
result = client.lookup(ip);
} catch (VPNDetectionException e) {
getLogger().warning("Let " + name + " (" + ip + ") in unchecked: "
+ e.getMessage());
return;
}
if (result.isTor().orElse(false) || result.isResproxy().orElse(false)) {
getLogger().info("Refused " + name + " (" + ip
+ "): Tor or residential proxy");
event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, REFUSED);
return;
}
if (result.isVpn()) {
String provider = result.vpn()
.map(vpn -> vpn.getProvider()).orElse("unknown");
String note = name + " joined through a VPN (" + provider + ")";
getLogger().info(note + " from " + ip);
getServer().getScheduler().runTask(this, () ->
getServer().broadcast(Component.text(note), "vpncheck.notify"));
}
}
}The same four players, joining Paper directly, got the same results, and Paper logged:
[VPNCheck] Steve joined through a VPN (mullvad) from 45.134.140.130
[VPNCheck] Refused Kai (198.98.51.189): Tor or residential proxy
Refuse, restrict or flag
Both versions refuse only Tor exits and residential proxies. A residential proxy exit is someone else's home connection, rented out, and a Tor exit is shared by anyone who picks it, so neither is how a regular player connects. Plenty of regular players do use a VPN, so a VPN gets a line in the log and a message to every player with the vpncheck.notify permission, which you grant your staff in your permissions plugin.
To ask more of a VPN player without refusing everyone on one, the verdict is in apply() to change. Refusing a VPN only for an account that has never joined from a clean address stops a fresh alt while letting regulars keep their VPN, and a restricted rank until staff verify the player is another way. Either needs a record of accounts, kept by your plugin or your permissions plugin.
Absent is not false
We ran the proxy with no key. Kai, on the Tor exit, got in:
[vpncheck]: Kai joined through a VPN (unknown) from 198.98.51.189
Noor, on the residential proxy, got in with nothing logged. The free plan answers ip and is_vpn only. Tor flags start on Starter, proxy flags on Scale, and the VPN's provider on Starter (pricing). On the free plan, isTor() and isResproxy() are empty Optionals, meaning "not in your plan" rather than "checked, and no", and orElse(false) reads them as no. This Tor exit is in our VPN data too, which is why the free plan still flagged Kai as a VPN, with no provider. In your own code, result.isTor().isEmpty() means the plan didn't include the check.
When the API is slow or down
Both versions let the player in and log a warning when a lookup fails. With api.vpndetection.io pointed at 127.0.0.1 in the proxy's hosts file:
[vpncheck]: Let Steve (45.134.140.130) in unchecked: request failed
With it pointed at an address that never answers:
[vpncheck]: Let Steve (45.134.140.130) in unchecked: request timed out after 2000ms
That login took two seconds longer, and Paper's version logged the same timeout. Refusing every player you couldn't check would make any outage of ours an outage of your server, so both fail open.
Next steps
For the website of the same network, the Spring Boot integration is built on the same library. The library is on Maven Central with its source on GitHub. Every field the API answers is in the docs, and the pricing page lists which plan answers which.


