8 topical docs (overview, data types, lifecycle, handshake, status/ping, login+encryption, configuration, version-differences) + proxy-forwarding set + 16 per-version release-line docs, sourced from minecraft.wiki, ViaVersion (source + commits), minecraft-data, node-minecraft-protocol, Velocity, BungeeCord. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.9 KiB
Velocity Modern Forwarding
The gold-standard forwarding mode. The proxy and every backend share a secret string; the proxy signs the forwarded identity with HMAC-SHA256 keyed by that secret, and the backend refuses to start the player's session unless the HMAC verifies. An attacker who reaches the backend's port still cannot forge an identity, because they don't have the secret — so this mode is safe even if the backend port is exposed, unlike legacy (firewall-only) forwarding.
It is called "modern" because, unlike legacy, it does not abuse the handshake address field. It rides the protocol's own Login Plugin Message mechanism (login-state plugin channels), which is a clean, in-band request/response added in the modern protocol.
Setup
- Proxy (Velocity):
player-info-forwarding-mode = "modern"and aforwarding.secret(a random secret string, stored in aforwarding.secretfile). - Backend (Paper):
paper.yml(orconfig/paper-global.yml) →proxies.velocity.enabled: true,proxies.velocity.online-mode: true,proxies.velocity.secret: <same secret>. Fabric/Forge backends use a mod (e.g. FabricProxy-Lite) that implements the same handshake.
The backend still runs online-mode=false at the vanilla level — modern forwarding is its identity source.
The handshake direction (important)
Normal Login Plugin Messages go server→client. Here the roles are inverted: the backend is the "server" and the proxy is the "client" of that backend connection. So:
- The proxy connects to the backend and sends the normal Handshake + LoginStart (just the username; no identity stuffed into the address — that is the whole difference from legacy).
- The backend sends a Login Plugin Request on channel
velocity:player_info, asking "who is this, and prove it." It may include one byte: the highest forwarding version the backend supports. - The proxy replies with a Login Plugin Response carrying the HMAC-signed identity payload.
- The backend verifies the HMAC, reads the identity, and proceeds. If no
velocity:player_inforequest was answered by login's end, Velocity aborts with "If you are a server owner, make sure you have ...forwarding-secret." (MODERN_IP_FORWARDING_FAILURE).
The channel name and version constants are defined in Velocity source:
public static final String CHANNEL = "velocity:player_info"; public static final int MODERN_DEFAULT = 1; // base payload public static final int MODERN_WITH_KEY = 2; // + 1.19 profile key public static final int MODERN_WITH_KEY_V2 = 3; // + signer UUID (1.19.1+) public static final int MODERN_LAZY_SESSION = 4; // 1.19.3+, key dropped again public static final int MODERN_MAX_VERSION = MODERN_LAZY_SESSION;—
Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:42-48
(The Velocity-side request handler reads the backend's requested version: if (packet.content().readableBytes() == 1) requestedForwardingVersion = packet.content().readByte(); — backend/LoginSessionHandler.java:89-93.)
Exact payload layout
The proxy builds the response body in PlayerDataForwarding.createForwardingData(...). The signed payload is, in order:
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | forwarding version | VarInt | the negotiated version (1–4) |
| 2 | client IP | String | the player's real remote address |
| 3 | player UUID | UUID | the real (online) UUID — 16 bytes, not a string |
| 4 | username | String | |
| 5 | game-profile properties | Properties | array of {name, value, signature?} — includes the Mojang-signed textures (skin/cape) |
| 6 | (if version 2–3) player public key | IdentifiedKey | 1.19 message-signing key |
| 7 | (if version ≥ 3) signer UUID present? + UUID | Boolean [+ UUID] | the key's signature-holder UUID, when known |
Then the whole thing is prefixed by its HMAC. The signed buffer is built first, the MAC is computed over exactly those bytes, and the 32-byte HMAC is concatenated in front:
ProtocolUtils.writeVarInt(forwarded, actualVersion); ProtocolUtils.writeString(forwarded, address); ProtocolUtils.writeUuid(forwarded, profile.getId()); ProtocolUtils.writeString(forwarded, profile.getName()); ProtocolUtils.writeProperties(forwarded, profile.getProperties()); // ... (optional player key / signer UUID for versions 2–3) ... final Mac mac = Mac.getInstance(ALGORITHM); // "HmacSHA256" mac.init(new SecretKeySpec(secret, ALGORITHM)); // keyed by the shared secret mac.update(forwarded.array(), forwarded.arrayOffset(), forwarded.readableBytes()); final byte[] sig = mac.doFinal(); return Unpooled.wrappedBuffer(Unpooled.wrappedBuffer(sig), forwarded);—
Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:69-102(ALGORITHM = "HmacSHA256"at:40)
So the wire body of the Login Plugin Response is:
[ HMAC-SHA256(secret, payload) : 32 bytes ] ++ [ payload ]
^ VarInt version, String IP, UUID,
String name, Properties, (key…)
The backend recomputes HMAC-SHA256(secret, payload) over the bytes after the first 32, and rejects the login if it doesn't match the prefix. Because the secret never crosses the wire and the IP/UUID/name/props are all inside the MAC'd region, nothing can be tampered with or forged without the secret. That is the security win over legacy and BungeeGuard.
Sequence
sequenceDiagram
autonumber
actor C as "Client (player)"
participant P as "Proxy (Velocity, mode=modern, secret=S)"
participant B as "Backend (Paper, velocity.secret=S, offline-mode)"
C->>P: "Handshake + LoginStart (username)"
Note over P: "online-mode auth vs Mojang (hasJoined)"
P->>B: "Handshake + LoginStart (username only)"
B->>P: "Login Plugin Request 'velocity:player_info' (+ max ver byte)"
Note over P: "payload = version,IP,UUID,name,props[,key]"
Note over P: "sig = HMAC-SHA256(S, payload)"
P->>B: "Login Plugin Response: sig(32B) ++ payload"
Note over B: "recompute HMAC(S, payload); compare to sig"
alt "HMAC matches"
B-->>C: "LoginSuccess (via proxy relay) -> play"
else "HMAC mismatch / no response"
B-->>P: "disconnect (forwarding failure)"
end
Why it's the gold standard
| Property | Modern | Legacy | BungeeGuard |
|---|---|---|---|
| Identity tamper-proof | Yes (whole payload HMAC'd) | No | partial (token gates, but IP/UUID still plaintext) |
| Safe with backend port exposed | Yes | No (firewall-only) | mostly (attacker lacks token) |
| Secret crosses the wire | No (only the HMAC does) | n/a | Yes (token sent in cleartext properties) |
| Uses protocol's own channel mechanism | Yes (Login Plugin Message) | No (address-field abuse) | No (address-field abuse) |
The only catch is backend support: every backend must implement modern forwarding (Paper natively; Fabric/Forge via a mod). Where a backend can only speak legacy, fall back to BungeeGuard. For Forge interplay, see forge-fml.md.
Login Plugin Message reference
The transport is the protocol's login-state plugin messaging (see also ../05-login-encryption.md):
- Login Plugin Request (clientbound, login state, packet
0x04): Message ID (VarInt), Channel (Identifier), Data (byte array, channel-specific, no length prefix). Here the backend sends it. - Login Plugin Response (serverbound, login state, packet
0x02): Message ID (VarInt), then a prefixed-optional Data byte array — present only if the request was understood. An unrecognized channel is answered with the empty/"not understood" form. Here the proxy sends it, with Data =sig ++ payload.
— minecraft.wiki — Java Edition protocol (Login Plugin Request / Response)
Sources
Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/PlayerDataForwarding.java:40(ALGORITHM="HmacSHA256"),:42-48(channel + forwarding-version constants),:57-111(createForwardingData— payload order + HMAC prefix),:99-102(MAC over the payload,wrappedBuffer(sig, forwarded)).Velocity/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/LoginSessionHandler.java:83-106(backend'svelocity:player_inforequest handling → proxy reply),:62+:146-147(MODERN_IP_FORWARDING_FAILUREwhen no info was forwarded).- minecraft.wiki — Java Edition protocol — Login Plugin Request (0x04) / Response (0x02) field layout.