Per-player Minecraft server authentication. One jar for Forge 1.7.10 through NeoForge 1.21.x, Fabric and vanilla — choose exactly which usernames are verified against Mojang instead of one server-wide online-mode switch.
  • Java 95.3%
  • PowerShell 4.7%
Find a file
NetworkChorus b0b374992a README: carry legacy support through the sections it was missing from
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>
2026-07-26 10:46:19 -06:00
poc 0.2.0 — extend down to Minecraft 1.3.2, and support Legacy Fabric / BTWCE 2026-07-26 10:41:10 -06:00
resources 0.2.0 — extend down to Minecraft 1.3.2, and support Legacy Fabric / BTWCE 2026-07-26 10:41:10 -06:00
src/net/selectiveauth 0.2.0 — extend down to Minecraft 1.3.2, and support Legacy Fabric / BTWCE 2026-07-26 10:41:10 -06:00
stubs 0.2.0 — extend down to Minecraft 1.3.2, and support Legacy Fabric / BTWCE 2026-07-26 10:41:10 -06:00
.gitignore SelectiveAuth 0.1.0 — per-player Minecraft authentication in one jar 2026-07-26 09:36:41 -06:00
build.ps1 0.2.0 — extend down to Minecraft 1.3.2, and support Legacy Fabric / BTWCE 2026-07-26 10:41:10 -06:00
LICENSE SelectiveAuth 0.1.0 — per-player Minecraft authentication in one jar 2026-07-26 09:36:41 -06:00
README.md README: carry legacy support through the sections it was missing from 2026-07-26 10:46:19 -06:00

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 a NEW, and invokes a no-arg ()Z feeding an IFEQ whose owner matches the getKeyPair() 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, not ProbeClient.
  • The discriminator is different. The server sends an encryption request either way — encryption is not optional. What signals intent is the serverId inside 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-mode and skins-for-unauthenticated have 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 preLaunch Fabric entrypoint. main and server are 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. preLaunch is 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