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.