Block VPNs on Game Servers and Discord

Stop banned players and alt accounts coming back through VPNs, proxies and Tor on Minecraft, FiveM and Discord.

Banned players come back on a VPN within minutes. Checking each player’s IP when they connect makes bans stick, and stops alt accounts made from cloud servers. Every example here lets players in if the lookup fails, so an outage never locks out your community.

1. Get an API key

Create a free account and copy your API key from the dashboard. The Free plan includes 10,000 lookups a month. Keep the key on your server in an environment variable called VPNBLOCKER_KEY, never in code that runs in the browser.

2. Minecraft (Paper or Spigot)

Check players in AsyncPlayerPreLoginEvent. It runs off the main thread, so a short HTTP request doesn’t lag the server. Put your key in the plugin’s config.yml as api-key: "vpb_...".

package com.example.vpnblock;

import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
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.Set;
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 VpnBlockPlugin extends JavaPlugin implements Listener {
    private static final Set<String> BLOCK = Set.of("vpn", "proxy", "tor");
    private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
    private String apiKey;

    @Override
    public void onEnable() {
        saveDefaultConfig();
        apiKey = getConfig().getString("api-key", "");
        getServer().getPluginManager().registerEvents(this, this);
    }

    @EventHandler
    public void onPreLogin(AsyncPlayerPreLoginEvent event) {
        String ip = event.getAddress().getHostAddress();
        try {
            HttpRequest req = HttpRequest.newBuilder(URI.create("https://api.vpnblocker.net/v2/json/" + ip))
                .header("X-API-KEY", apiKey)
                .timeout(Duration.ofSeconds(3))
                .build();
            HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
            if (res.statusCode() != 200) return; // let players in if the lookup fails

            JsonObject r = JsonParser.parseString(res.body()).getAsJsonObject();
            boolean blockable = false;
            for (JsonElement c : r.getAsJsonArray("categories")) {
                if (BLOCK.contains(c.getAsString())) blockable = true;
            }
            if (blockable && r.get("confidence").getAsInt() >= 90) {
                event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER,
                    "VPNs, proxies and Tor aren't allowed on this server.");
            }
        } catch (Exception e) {
            getLogger().warning("VPN Blocker lookup failed: " + e.getMessage());
        }
    }
}

Players often rejoin, so consider caching answers for an hour in a ConcurrentHashMap to save lookups.

3. FiveM

Use deferrals in playerConnecting so the player waits while you check. Add your key to server.cfg with set vpnblocker_key "vpb_...".

local apiKey = GetConvar('vpnblocker_key', '')
local block = { vpn = true, proxy = true, tor = true }

AddEventHandler('playerConnecting', function(name, setKickReason, deferrals)
    local src = source
    deferrals.defer()
    Wait(0)
    deferrals.update('Checking your connection...')

    local ip = (GetPlayerIdentifierByType(src, 'ip') or ''):gsub('^ip:', '')
    if ip == '' then return deferrals.done() end

    PerformHttpRequest('https://api.vpnblocker.net/v2/json/' .. ip, function(status, body)
        if status ~= 200 or not body then return deferrals.done() end -- let players in if the lookup fails
        local r = json.decode(body)
        for _, c in ipairs(r.categories or {}) do
            if block[c] and r.confidence >= 90 then
                return deferrals.done('VPNs, proxies and Tor are not allowed on this server.')
            end
        end
        deferrals.done()
    end, 'GET', '', { ['X-API-KEY'] = apiKey })
end)

Other game servers

Any server that can make an HTTPS request when a player connects can do the same: look up the player’s IP, then refuse the connection when categories contains vpn, proxy or tor with confidence of 90 or more. See the docs for more rules.

4. Discord

Discord never shares members’ IP addresses with bots, so a bot can’t check them directly. The usual approach is a verification link: the bot sends new members a link to a page on your website, the page checks their IP, and the bot gives them a Verified role if they pass. Lock your channels to that role.

This example uses discord.js and Express, with lookup() and decide() from the Node.js guide. Turn on the Server Members Intent for your bot in the Discord developer portal, and make sure the bot’s role is above the Verified role.

import { Client, Events, GatewayIntentBits } from 'discord.js';
import express from 'express';
import { randomBytes } from 'node:crypto';
import { lookup, decide } from './vpnblocker.js'; // from the Node.js guide

const BASE_URL = 'https://verify.example.com';
const VERIFIED_ROLE_ID = '123456789012345678';
const pending = new Map(); // token -> { guildId, userId, expires }

const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });

client.on(Events.GuildMemberAdd, async (member) => {
  const token = randomBytes(16).toString('hex');
  pending.set(token, { guildId: member.guild.id, userId: member.id, expires: Date.now() + 15 * 60 * 1000 });
  await member.send(`Welcome! Verify here to get access: ${BASE_URL}/verify/${token}`).catch(() => {});
});

const app = express();
app.set('trust proxy', 1); // only if you're behind exactly one proxy or load balancer

// The page has a button, so link previews that open the URL don't verify anyone.
app.get('/verify/:token', (req, res) => {
  res.send('<form method="post"><button>Verify me</button></form>');
});

app.post('/verify/:token', async (req, res) => {
  const entry = pending.get(req.params.token);
  if (!entry || entry.expires < Date.now()) {
    return res.status(410).send('This link has expired. Ask a moderator for a new one.');
  }

  const r = await lookup(req.ip.replace(/^::ffff:/, ''));
  if (r && decide(r) !== 'allow') {
    return res.status(403).send('Please turn off your VPN, proxy or Tor, then try again.');
  }

  pending.delete(req.params.token);
  const guild = await client.guilds.fetch(entry.guildId);
  const member = await guild.members.fetch(entry.userId);
  await member.roles.add(VERIFIED_ROLE_ID);
  res.send('You’re verified. You can close this page and go back to Discord.');
});

client.login(process.env.DISCORD_TOKEN);
app.listen(3000);

Here only allow passes, so challenge answers are refused too. If you’d rather show a captcha for those, add one to the verification page.

Protect your server today

Create a free account and make your first lookup in minutes. 10,000 lookups a month at no charge.