Skip to content
VPNDetection

Block VPN players on Minecraft: a Velocity and Paper plugin

A Java plugin for Velocity and Paper that checks each player with our API: Tor and residential proxies refused, VPNs flagged to staff, tested live.

Published 6 min read

On this page
  1. Add the library
  2. Check players on the proxy
  3. What the backend sees
  4. One Paper server, no proxy
  5. Refuse, restrict or flag
  6. Absent is not false
  7. When the API is slow or down
  8. Next steps

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
<?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:

properties
api-key=

Check players on the proxy

java
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:

PlayerAddressResult
Steve45.134.140.130, a Mullvad server in Atlanta from Mullvad's server listJoined, staff told
Alexa home broadband lineJoined
Kai198.98.51.189, a Tor exit from the Tor Project's exit listRefused
Noora residential proxy exitRefused

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-modeAddress Paper checkedKai, on a Tor exit
"NONE", Velocity's defaultVelocity's ownJoined
"MODERN"198.98.51.189Refused

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:

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:

yaml
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:

xml
<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:

yaml
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:

yaml
api-key: ""

And the plugin:

java
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.

Written by

  • Uman Shahzad

    Founder & CEO

    IP intelligence data expert for over 8 years, former lead engineer at IPinfo, Uman has pioneered many systems and practices used by IP data companies to operate at scale, provide high-quality data and offer the best developer experiences.

Get new posts by email

Or follow along in a feed reader.

RSS feed

Try it on your own traffic.

The detection these posts describe is one API call away, with SDKs for every major language.

00 · No key

Start without signing up.

Paste it into a terminal. No account needed. 1k daily allowance per user, answering ip and is_vpn.

curl "https://api.vpndetection.io/45.83.91.1"
{
"ip": "45.83.91.1",
"is_vpn": true
}
01 · Free key

Sign up and get 50,000 requests a month.

No card and no sales call: the key is issued on signup.
Every request flags VPN IPs.
Upgrade only when you outgrow the allowance.