- Java 95.3%
- PowerShell 4.7%
The 0.2.0 commit added the pre-1.7 / BTWCE section and the status rows but left four places behind: * every command example still said 0.1.0 * Installing described only the Forge routes -- now a table of all four, with why preLaunch is the one that counts on Fabric * the live-evidence table stopped at four servers and did not mention that pre-1.7 uses a different probe and a different signal * Testing listed neither LegacyProbeClient, StringScan, nor setup-legacy.ps1 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| poc | ||
| resources | ||
| src/net/selectiveauth | ||
| stubs | ||
| .gitignore | ||
| build.ps1 | ||
| LICENSE | ||
| README.md | ||
SelectiveAuth
Per-player authentication for Minecraft servers. Instead of one server-wide
online-mode switch, you choose exactly which usernames are verified against
Mojang.
Two modes, one predicate:
- EXCLUDE — the server runs offline, and only the names you list are pushed through Mojang authentication. They alone get verified identities and skins.
- INCLUDE — the server runs online, and every name is authenticated except the ones you list.
Server-side only. No client mod: the client simply answers whatever the server asks it.
Download: releases — one ~58 KB jar, no dependencies.
One jar, every loader, every version
A single ~58 KB jar covers Minecraft 1.3.2 through 1.21.x — Forge, NeoForge,
Fabric, Legacy Fabric and Quilt, plus plain -javaagent on vanilla. Three
decisions make that possible.
1. Hooks are found structurally, not by name
The class holding Minecraft's auth branch is named differently in every single version. Across the four naming schemes this has been tested against:
obfuscated nn / pc / aba / aeq / aja / aoe / ash / arw / atg / gx / it / jf / jy
SRG ServerLoginPacketListenerImpl.m_5990_ gate m_129797_ field f_10021_
Mojang ServerLoginPacketListenerImpl.handleHello gate usesAuthentication
intermediary class_3248.method_12641 gate method_3828 field field_14160
LF intermediary class_801.method_1861 gate method_2984 field field_2888
So we do not look for a name. We look for a shape:
a void instance method taking one object parameter, whose body invokes something returning
java.security.KeyPair, contains aNEW, and invokes a no-arg()Zfeeding anIFEQwhose owner matches thegetKeyPair()receiver.
getKeyPair() returns a JDK type, so its descriptor survives every remapping.
The owner-match clause distinguishes usesAuthentication() from the
!isMemoryConnection() check beside it in the same condition, using no names at
all. The username field is likewise read out of the bytecode — the last
PUTFIELD of a String or GameProfile before the branch — which tracks
Minecraft's own switch from GameProfile to String between 1.20.1 and 1.20.4
with no special-casing.
2. Every patch is length-preserving, so there is no ASM at runtime
ASM refuses to parse class files newer than the version it was built against, which would mean breaking on Minecraft versions that do not exist yet. (The project's own ASM-based test harness hit exactly that on class file 65.) Instead a hand-written class-file patcher makes same-length swaps, so stack depth and the StackMapTable stay valid and never need recomputing.
| site | original | replacement | purpose |
|---|---|---|---|
| gate | ALOAD_0 GETFIELD INVOKEVIRTUAL (7B) |
ALOAD_0 INVOKESTATIC NOP NOP NOP (7B) |
who gets authenticated |
| offline identity | INVOKESTATIC createOfflineProfile(String) (3B) |
INVOKESTATIC bridge (3B) |
uuid-mode, skins |
| offline identity (≤1.20.1) | INVOKEVIRTUAL getOfflineProfile(GameProfile) (3B) |
INVOKESTATIC bridge (3B) |
uuid-mode, skins |
| authenticated path | INVOKEINTERFACE hasJoinedServer (5B) |
INVOKESTATIC bridge NOP NOP (5B) |
uuid-mode=FORCE_OFFLINE |
| vanilla bundler | INVOKEVIRTUAL ClassLoader.getParent() (3B) |
NOP NOP NOP (3B) |
make Minecraft able to see us |
The hasJoinedServer site needs no fingerprint at all: MinecraftSessionService
is an authlib type, and authlib is a library Minecraft never remaps, so its
owner and method name are the same string in every version ever shipped. Its
signature has changed repeatedly though, so both the arity and the return
type are read from the call site — modern authlib returns
com.mojang.authlib.yggdrasil.ProfileResult (a record wrapping the profile)
rather than GameProfile, and assuming otherwise produces a VerifyError.
Bridge methods
The three profile-related sites do not call our code directly. They call a small
synthetic static method that the patcher appends to the Minecraft class
itself:
ALOAD_0
INVOKESTATIC net/selectiveauth/Profiles.offlineIdentity (Ljava/lang/String;)Ljava/lang/Object;
CHECKCAST com/mojang/authlib/GameProfile
ARETURN
This exists for one reason. If our own methods declared GameProfile, the JVM
would resolve that type against our defining loader — and on vanilla 1.18+
our jar sits on the application class path while authlib lives inside
Minecraft's child loader, which a parent can never see. Every login died with
NoClassDefFoundError. A bridge injected into the Minecraft class is defined by
the loader that does see authlib, so the cast resolves there while our side
stays Object-typed and names no authlib type anywhere.
Appending a method means bumping methods_count and inserting a method_info
before the attributes section. No StackMapTable is emitted because the bridges
are branch-free, and frames are only required at branch targets.
The patcher refuses to act if a region is not the expected length or if any jump
lands inside it. Every patched class — including the bridge-bearing ones and the
hasJoinedServer class — is checked against the real JVM verifier.
3. Delivery is a java agent
A ClassFileTransformer sees every class from every classloader as it is
defined, so we never touch a loader-specific transformer API. This also handles
Forge 1.7–1.12, which ships binpatches.pack.lzma and rewrites Minecraft at
runtime — a tool that patched jars on disk would never see the real bytecode.
The jar carries mcmod.info, META-INF/mods.toml,
META-INF/neoforge.mods.toml and fabric.mod.json simultaneously; each loader
finds its own and ignores the rest.
Installing
Recommended — always works:
java -javaagent:selectiveauth-0.2.0.jar -jar server.jar nogui
Drop-in: put the jar in mods/. It self-attaches. Note that JEP 451 means a
future JDK will disallow dynamic agent loading by default; when that lands you
will need -XX:+EnableDynamicAgentLoading, or the -javaagent form above.
Drop-in works on every supported loader, by a different route on each:
| loader | how it gets in |
|---|---|
| Forge 1.13+ / NeoForge | mods.toml / neoforge.mods.toml @Mod → self-attach |
| Forge 1.7.10 – 1.12.2 | LaunchWrapper tweaker (TweakClass) — no attach API needed |
| Fabric / Quilt / Legacy Fabric | preLaunch entrypoint → self-attach |
| vanilla / anything else | -javaagent |
On Forge 1.7.10–1.12.2 the tweaker route matters because those servers often run
a JRE with no tools.jar, so self-attach is unavailable there.
On Fabric the preLaunch entrypoint is the one that counts. main and
server are invoked through a hook the game provider injects into Minecraft's
own startup, and Legacy Fabric's 1.6.4 provider injects neither on a dedicated
server — the mod loads, appears in the mod list, and silently does nothing.
preLaunch is invoked by Knot itself, so it fires on every Fabric variant, and
it runs before any Minecraft class is defined.
FMLCorePlugin cannot be used for this: the attribute names exactly one class,
but 1.7.10 needs cpw.mods.fml.relauncher.IFMLLoadingPlugin while 1.8+ needs
net.minecraftforge.fml.relauncher.IFMLLoadingPlugin, and naming either makes
FML hard-crash on the other era. ITweaker belongs to LaunchWrapper rather than
FML and has the same name on both.
Configuration
selectiveauth.properties, in config/ if it exists, otherwise the server
directory. Edits apply within about two seconds — no restart, except
uuid-mode, which decides at startup whether a patch site is installed at all.
mode=EXCLUDE
names=alice,bob
enabled=true
uuid-mode=NATIVE
skins-for-unauthenticated=false
reserve-premium-names=false
Names are case-insensitive.
uuid-mode
| value | authenticated player | unverified player |
|---|---|---|
NATIVE |
real Mojang UUID | offline hash |
FORCE_OFFLINE |
offline hash, real skin kept | offline hash |
FORCE_ONLINE |
real Mojang UUID | real Mojang UUID of that name |
NATIVE is vanilla behaviour, and its sharp edge is that moving a name between
lists changes that player's UUID — new inventory, new advancements, dropped
op/whitelist entries. FORCE_OFFLINE exists to remove that edge: identity stays
put no matter how you edit the list, and verified players still get their real
skin.
FORCE_ONLINE is genuinely dangerous and off by default. It lets an unverified
client inherit a real account's playerdata and permissions just by typing their
name. Only use it behind a whitelist.
There are no in-game commands, deliberately
Command registration is ICommand on 1.7.10 and Brigadier from 1.13, with no
common shape and no stable structural anchor to find either. Rather than ship
commands for some versions and not others, the config file is watched and
reloaded. That behaves identically everywhere, needs no Minecraft API, and gives
operators the thing they actually wanted: changing the list without a restart.
Things that will bite you
enforce-secure-profile (1.19+). An unauthenticated player has no Mojang
profile key, cannot sign chat, and is disconnected during login regardless of
what this mod does. Set it to false. SelectiveAuth warns loudly at startup if
it finds this combination, because the symptom is indistinguishable from "the mod
didn't work".
Name squatting. In EXCLUDE mode an unverified client can otherwise claim any
name not on your list, including a real account's. Turn on
reserve-premium-names: if an unverified name belongs to a real Mojang account,
authentication is demanded for it anyway. An imposter then fails the handshake
and Minecraft kicks them; the genuine owner passes. A Mojang API outage leaves
the decision untouched rather than locking anyone out.
Behind a proxy (Velocity, BungeeCord), authentication happens at the proxy, so this mod belongs there instead — it will do nothing useful on a backend server.
On pre-1.7 servers, uuid-mode and skins-for-unauthenticated do nothing.
Minecraft had no UUIDs before 1.7 — authlib arrived in 1.7.2, and until then a
player simply is their username. There is no UUID to rewrite, and the client
fetches skins by name on its own. SelectiveAuth says so loudly at startup rather
than letting you assume the setting took effect. Selective authentication itself
works normally there, and so does reserve-premium-names, which only ever needed
the name.
Antivirus and platform review. A jar that self-attaches an agent looks like malware to heuristic scanners. That is inherent to the one-jar design.
Failure behaviour
Every failure path falls back to exactly what vanilla would have done. If the
fingerprint does not match, if reflection fails, if the username cannot be read —
the server behaves as its online-mode dictates and says so in the log. The two
alternatives (authenticate nobody, or authenticate everybody) are both worse than
being a no-op. Where vanilla's own answer cannot be read back, the fallback comes
from server.properties, not from a hardcoded guess.
Building
pwsh build.ps1
Needs a JDK 17 at C:\Program Files\Java\jdk-17 (the system default is 26, which
has dropped -source 8). Output lands in dist/. Everything compiles to Java 8
bytecode, the floor Forge 1.7.10 requires and which every later JVM still
accepts. Loader APIs and com.mojang.authlib.GameProfile are compiled against
throwaway stubs in stubs/; the build fails if one leaks into the jar.
Status
| Fingerprint finds the gate | verified, 12/12 jars |
| Patches pass the JVM verifier | verified, 11/11 jars |
| Username field located from bytecode | verified |
| EXCLUDE / INCLUDE decide correctly | verified |
| uuid-mode NATIVE / FORCE_OFFLINE / FORCE_ONLINE | verified |
| skins-for-unauthenticated | verified (real signed textures) |
| reserve-premium-names | verified (live Mojang API) |
| hot config reload | verified |
| Live Forge 1.12.2 (Java 8 JRE, tweaker) | verified |
| Live Forge 1.20.1 (SRG, self-attach) | verified |
| Live NeoForge 1.21.1 (Mojmap, self-attach) | verified |
| Live Fabric 1.20.1 (intermediary, self-attach) | verified |
Live vanilla 1.21.1 (-javaagent, bundler) |
verified |
| Live Legacy Fabric 1.6.4 (LF intermediary) | verified |
| Live BTWCE 3.1.0 on 1.6.4 / Java 17 | verified, drop-in |
| Real Mojang login, real account | verified — see below |
| Imposter refused (no Mojang session, listed name) | verified |
| Paper / Velocity | not tested |
Real-account verification
On live vanilla 1.21.1 with online-mode=false, mode=EXCLUDE,
names=amr2010, uuid-mode=FORCE_OFFLINE:
login 'amr2010' -> AUTHENTICATE (vanilla would have been unverified)
FORCE_OFFLINE: 'amr2010' authenticated, using offline uuid 02526b41-c453-3f72-997f-6d3d27f4901d
rebuilt ProfileResult with offline identity
[User Authenticator #1]: UUID of player amr2010 is 02526b41-c453-3f72-997f-6d3d27f4901d
amr2010 joined the game
Three independent proofs sit in those lines. Vanilla's own User Authenticator
thread ran, which only happens when the server really calls Mojang — impossible
on an offline-mode server. The account's real skin rendered, so the session
handshake genuinely completed. And the UUID is the version-3 offline hash,
not the account's real version-4 5aa5bdfe-…, so FORCE_OFFLINE rewrote the
identity while keeping both the verification and the skin.
Pre-1.7 servers, and Better Than Wolves: CE
The floor is 1.3.2, and that is not arbitrary: 1.3.1 is where Mojang added
the encryption handshake. Before it there is no getKeyPair, no encryption, and
nothing worth forcing. 1.2.5 is correctly rejected by the fingerprint.
Everything below 1.7 differs from the modern protocol in ways worth knowing:
- The protocol is pre-netty. No VarInts, strings are length-prefixed UTF-16BE,
different packet ids. Use
LegacyProbeClient, notProbeClient. - The discriminator is different. The server sends an encryption request
either way — encryption is not optional. What signals intent is the
serverIdinside it: a real hash means "go verify with Mojang","-"means "don't bother". - Authentication goes to
session.minecraft.net/checkserver.jsp, not authlib. That endpoint is still live, which is why selective auth remains meaningful on these versions. - There are no UUIDs at all, so
uuid-modeandskins-for-unauthenticatedhave nothing to act on.
Better Than Wolves: Community Edition is the stack this was validated against, and it is an unusual one: Minecraft 1.6.4 on Legacy Fabric, running on Java 17–21 because it depends on GrossFabricHacks. It works drop-in, with all 12 of its mods loaded:
alice -> AUTHENTICATE serverId="-2bdbb7bf5b42fe41"
bob -> NO_AUTH serverId="-"
patched net/minecraft/class_801.method_1861
(gate MinecraftServer.method_2984()Z, username in field_2888)
Reproduce with poc/setup-legacy.ps1. Note it must run on Java 17 — BTWCE
rejects Java 8 outright at mod resolution.
Getting this working required adding the
preLaunchFabric entrypoint.mainandserverare invoked through a hook the game provider injects into Minecraft's startup, and Legacy Fabric's 1.6.4 provider injects neither on a dedicated server — the mod loads, shows up in the mod list, and silently does nothing.preLaunchis invoked by Knot itself, so it fires everywhere, and it is the better moment anyway: it runs before any Minecraft class is defined.
The imposter test
ProbeClient ... <name> imposter answers the Encryption Request the way a
cracked client must: a random shared secret genuinely RSA-encrypted with the
server's public key, but without ever calling Mojang's joinServer. The
crypto is valid; the session does not exist. Against a listed name:
login 'amr2010' -> AUTHENTICATE (vanilla would have been unverified)
[User Authenticator #3]: Disconnecting amr2010: Failed to verify username!
That is the security half of the feature: putting a name on the list makes authentication mandatory for it, so nobody without the real account can claim it.
Beware of testing this with a "cracked" launcher instead — most of them will use
a real Mojang session if one is configured, in which case the login legitimately
succeeds and proves nothing. Check the log for a completed User Authenticator
thread before concluding anything.
Live server evidence
Every server ran online-mode=false with mode=EXCLUDE, names=alice. A raw
protocol client observed the server's first reply. An Encryption Request from an
offline-mode server is something vanilla can never send, so it is direct proof
that authentication was forced for that name alone:
| server | alice |
bob |
|---|---|---|
| Forge 1.12.2 | ENCRYPTION_REQUEST |
LOGIN_SUCCESS |
| Forge 1.20.1 | ENCRYPTION_REQUEST |
LOGIN_SUCCESS |
| NeoForge 1.21.1 | ENCRYPTION_REQUEST |
LOGIN_SUCCESS |
| Fabric 1.20.1 | ENCRYPTION_REQUEST |
LOGIN_SUCCESS |
| vanilla 1.21.1 | ENCRYPTION_REQUEST |
LOGIN_SUCCESS |
| Legacy Fabric 1.6.4 | AUTHENTICATE (serverId hash) |
NO_AUTH (serverId -) |
| BTWCE 3.1.0 on 1.6.4 | AUTHENTICATE (serverId hash) |
NO_AUTH (serverId -) |
The first five used poc/src/ProbeClient.java; the last two used
poc/src/LegacyProbeClient.java, because pre-1.7 is a different protocol with a
different signal — see Pre-1.7 servers.
What real Mojang login still needs
Everything above proves which branch the server takes. It does not prove that a genuine session handshake completes, because the probe never has a real session to present. That last step needs a paid Minecraft account (to confirm a listed player authenticates and arrives with their real skin) and a cracked client (to confirm an unlisted player is admitted and a listed one is refused).
Testing
# structural fingerprint across every server jar
java -cp "poc/out;poc/lib/*" FingerprintScan poc/jars/*.jar
# patch + real JVM verification (run on JDK 21; JDK 17 cannot load class file 65)
java -cp "poc/out;build/classes" PatchTest poc/jars/*.jar
# behaviour, under a real agent, with no Minecraft present
java -javaagent:dist/selectiveauth-0.2.0.jar -Dselectiveauth.dir=poc/itcfg-exclude \
-cp poc/itout IntegrationTest EXCLUDE # also INCLUDE, RESERVE, RELOAD
java -javaagent:dist/selectiveauth-0.2.0.jar -Dselectiveauth.dir=poc/itcfg-forceoffline \
-cp poc/itsout SessionTest FORCE_OFFLINE
java -javaagent:dist/selectiveauth-0.2.0.jar -Dselectiveauth.dir=poc/itcfg-forceonline \
-cp poc/itsout OfflineSiteTest FORCE_ONLINE true
# against a running server: which branch it takes, and whether an imposter is refused
java -cp poc/out ProbeClient 127.0.0.1 25565 767 2 <name>
java -cp poc/out ProbeClient 127.0.0.1 25565 767 2 <listed-name> imposter
# PRE-1.7 servers speak a different protocol -- use the legacy probe
# (protocol 78 = 1.6.4, 61 = 1.5.2, 39 = 1.4.7 / 1.3.2)
java -cp poc/out LegacyProbeClient 127.0.0.1 25574 78 <name>
# what a given version calls to authenticate (session.minecraft.net vs authlib)
java -cp poc/out StringScan poc/jars/server-1.6.4.jar
Server harnesses, all reproducible from scratch:
| script | builds |
|---|---|
poc/fetch-jars.ps1 |
vanilla server jars from Mojang's metadata service |
poc/fetch-modded.ps1 |
Forge and NeoForge servers |
poc/setup-servers.ps1 |
the modern live test servers |
poc/setup-legacy.ps1 |
Legacy Fabric 1.6.4 + BTWCE — run it on Java 17, BTWCE rejects Java 8 |