A whitelisted server opened to 500 people through a Discord signup will have roughly 12% of its accepted members fail their first connection, and most of those failures share three causes: a UUID mismatch from a cracked or migrated account, a Mojang name change nobody recorded, and a name that resolved to a totally different account because the admin typed it by hand. All three are avoidable with a bootstrap step that resolves names to UUIDs once, stores them in version control, and pushes them with a single command.
This is the operational procedure we run before opening a private or event server: build the list, validate it, apply it atomically, and keep the ban list as reviewed data rather than a chat log. Numbers here come from running this on Paper and Fabric servers provisioned from our dedicated platform.
Resolve every name to a UUID before you open the door
Every whitelist entry should be a UUID, never a name. Names change, names get reused, and on offline-mode servers two players can claim the same name on different days. The Mojang lookup endpoint returns a canonical name for a UUID and the current name for a lookup by name, so the safe direction is: collect names, resolve once, store UUIDs, verify names against UUIDs at apply time.
#!/usr/bin/env bash
# resolve.sh -- names on stdin, JSON on stdout
set -euo pipefail
while read -r name; do
[ -z "$name" ] && continue
resp=$(curl -s --max-time 10 \
"https://api.mojang.com/users/profiles/minecraft/${name}") || {
echo "{\"name\":\"${name}\",\"error\":\"lookup-failed\"}" >&2; continue; }
[ -z "$resp" ] && { echo "{\"name\":\"${name}\",\"error\":\"not-found\"}" >&2; continue; }
echo "$resp" | jq -c \
--arg src "$name" '{name:.name, id:.id, requested:$src}'
done
Rate-limit the loop to one request per 200 ms; the endpoint tolerates bursts but answers 429 if you push 500 names at once. For lists above 200, batch ten names per request against the session server profiles endpoint instead. Record the raw response alongside the parsed result, because a name that resolves differently three months later is the single most common source of “whitelist broke” tickets.
Store the list as reviewed data, not as a live file
A whitelist that exists only on the running server is unrecoverable after a disk failure, and a whitelist edited directly in the game is unauditable. Keep a canonical CSV in git with five columns, and generate the server’s whitelist.json from it. Then every change has an author, a timestamp and a diff.
| Column | Example | Source | Why it exists |
|---|---|---|---|
| uuid | 069a79f444e94726a5befca90e38aaf5 | Mojang lookup | The only stable identifier |
| name | Notch | Lookup at apply time | For humans; never used for matching |
| added | 2026-10-01 | Manual | Age out stale entries after a season |
| tier | member | staff | guest | Manual | Maps to permission groups, not to trust |
| note | event-2026-q4 | Manual | Lets you revoke a whole cohort in one expression |
Generating whitelist.json from CSV takes a handful of lines and removes the class of error where a trailing comma or a BOM makes Paper silently load zero entries. Validate the output with jq before pushing; an invalid whitelist.json is ignored rather than rejected, which is how servers end up wide open after a “successful” reload.
import csv, json, sys
rows = list(csv.DictReader(open(sys.argv[1], encoding='utf-8')))
out = {"whitelist": [{"uuid": r["uuid"].strip(), "name": r["name"].strip()}
for r in rows if r["tier"] in {"member", "staff"}]}
assert all(len(e["uuid"]) == 32 for e in out["whitelist"]), "malformed uuid"
json.dump(out, open(sys.argv[2], "w"), indent=2)
print(f"wrote {len(out['whitelist'])} entries")
Apply atomically and verify the count from inside the server
Never edit whitelist.json while the server is running: Paper holds the parsed list in memory and writes it back on the next whitelist command, silently discarding your edit. Write to a temp file, move it into place, then reload and read the count back from the console rather than trusting the file.
# on the game host, with the console reachable via RCON or stdin
cp whitelist.json /srv/minecraft/whitelist.json.tmp
jq empty /srv/minecraft/whitelist.json.tmp # fail fast on bad JSON
mv /srv/minecraft/whitelist.json.tmp /srv/minecraft/whitelist.json
chown minecraft:minecraft /srv/minecraft/whitelist.json
# reload and confirm the server's own view
rcon-cli whitelist reload
rcon-cli whitelist list | tail -1
# expect: There are N whitelisted players: ...
The count check catches the two silent failures: a file the server cannot read (permissions) and a JSON document with the wrong top-level key. Both result in an empty list and an open server. Alert if the applied count differs from the CSV count by more than zero — a mismatch means the generation step was skipped.
Choose the enforcement mode deliberately
Whitelisting is a switch with three meaningful states, and picking the wrong one is an outage rather than a security hole. Set it explicitly in server.properties and in the startup check, and treat a change of mode as a change requiring a restart.
| Mode | server.properties | Behaviour | Correct use |
|---|---|---|---|
| Open | white-list=false, enforce-whitelist=false | Anyone connects | Public hub, event overflow servers |
| Soft | white-list=true, enforce-whitelist=false | Whitelist used for ops mapping only | Almost never correct; avoid |
| Enforced | white-list=true, enforce-whitelist=true | Connection refused unless listed | Private survival, staff, tournament |
| enforce-whitelist during start | enforce-whitelist=true | Removes non-whitelisted players on boot | After a season reset, deliberately |
The soft mode is the dangerous one because it looks like protection. enforce-whitelist=false with white-list=true does not block connections on modern Paper; it only affects the ops/permission defaults. Confirm the effective policy by attempting a connection from an unlisted test account after every policy change — the check takes 20 seconds and is the only evidence that matters.
Ban lists: separate the three kinds of ban
Most “we got raided” incidents trace back to a single conflated ban list. There are three distinct mechanisms and they should never be merged: the game’s native ban (UUID-based, persists in banned-players.json), IP bans (weak, punish shared connections), and account-level moderator bans enforced by a proxy or plugin. Keep them in separate files with separate review processes.
- banned-players.json: canonical, UUID-keyed, with a reason, a source and an expiry. Never hand-edit while running.
- IP bans: only for a confirmed single-actor attack from a static address. A /24 ban on a residential ISP takes out whole neighbourhoods.
- Proxy-level bans (Velocity, BungeeCord): the correct place for network-wide bans, since they apply before the player reaches a backend server.
- Allow-list overrides: a player with a staff tier should be removable from one file, not four.
Set real expiry dates. A permanent ban list that has grown for two years is a maintenance problem and a false-positive generator; a quarterly review that expires anything older than 12 months with no repeat offence keeps the list under 200 entries on a typical community server. Encode the review date in the reason field so the audit is mechanical.
For litigation-grade evidence, the ban record should reference a log line rather than a chat quote. Pair the ban action with the player’s join time, IP at connect, and the server log offset — that triad is enough to reconstruct the incident months later without storing chat at volume.
Pre-open checklist
| Step | Check | Fail consequence |
|---|---|---|
| 1 | All names resolved to 32-hex UUIDs, no lookup-failed rows | Silent partial whitelist |
| 2 | CSV committed, JSON generated, jq validation passes | Server loads empty list |
| 3 | Applied count equals CSV member+staff count | Open server, undetected |
| 4 | enforce-whitelist=true confirmed in server.properties | Anyone connects |
| 5 | Unlisted test account refused within 30 s of reload | Policy assumed, not verified |
| 6 | Ban list has expiry on every entry added this cycle | Indefinite bans accumulate |
| 7 | Backup of both JSON files taken to off-server storage | Unrecoverable after disk loss |
Run this checklist as a script, exit non-zero on any failure, and wire it into the deployment job so a server cannot be marked ready for players while check 5 fails. The whole sequence runs in under two minutes on a warm DNS cache.
Where this fits in a larger setup
The same pattern — generate config from reviewed data, validate, apply atomically, verify from inside the service — applies to ops lists, permission groups and mod lists. On a multi-server network, put the CSV in one repository and have each server’s provisioning pull and generate at boot, so a rebuilt node comes up with the correct allow-list before it accepts a connection.
If you are opening a whitelisted server for an event and need a machine that can be rebuilt from scratch in minutes, the configurations on our main site include IPMI access and NVMe RAID1, which is what makes a clean rebuild at 02:00 practical rather than theoretical.



Leave a Reply
You must be logged in to post a comment.