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

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`](https://docs.vpndetection.io/integrations/java), 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](https://app.vpndetection.io): 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:

| Player | Address | Result |
|---|---|---|
| Steve | `45.134.140.130`, a Mullvad server in Atlanta from [Mullvad's server list](https://mullvad.net/en/servers) | Joined, staff told |
| Alex | a home broadband line | Joined |
| Kai | `198.98.51.189`, a Tor exit from the [Tor Project's exit list](https://check.torproject.org/torbulkexitlist) | 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`:

```toml
player-info-forwarding-mode = "MODERN"
```

Then, per [Paper's forwarding guide](https://docs.papermc.io/velocity/player-information-forwarding/), `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](https://vpndetection.io/pricing)). On the free plan, `isTor()` and `isResproxy()` are empty `Optional`s, 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](https://docs.vpndetection.io/integrations/spring-boot) is built on the same library. The library is on [Maven Central](https://central.sonatype.com/artifact/io.vpndetection/vpndetection) with its source on [GitHub](https://github.com/vpndetection-io/sdk-java). Every field the [API](https://vpndetection.io/api) answers is in the [docs](https://docs.vpndetection.io), and the [pricing](https://vpndetection.io/pricing) page lists which plan answers which.

