Corrected real errors: several 1.7.x release dates, resource_pack_send version, config packet ordering, structured-component count (56), PLAYER_LOADED (1.21.4), entity_sound_effect field order. Confirmed+cited the rest; remaining ~19 items re-marked UNCONFIRMED (third-party/ViaLegacy/26.2 internals unreachable from refs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
03 — Handshake Packet
State: Handshaking → Login or Status
Direction: Client → Server
Packet ID: 0x00
Stable since: 1.7.2
The Handshake packet is the first packet every modern Minecraft client sends. It is sent exactly once per TCP connection, transitions the protocol state, and carries three pieces of metadata that have been overloaded with proxy and modloader signalling data over the years.
Packet fields
| # | Field | Type | Notes |
|---|---|---|---|
| 1 | Protocol Version | VarInt | Negotiated version; e.g. 47 = 1.8, 340 = 1.12.2, 765 = 1.20.4, 766 = 1.20.5, 775 = 26.1 |
| 2 | Server Address | String (255) | Hostname/IP the client used to connect — subject to all the overloads below |
| 3 | Server Port | Unsigned Short | Default 25565 |
| 4 | Intent (Next State) | VarInt enum | 1 = Status, 2 = Login, 3 = Transfer (1.20.5 / protocol 766+) |
Source: minecraft.wiki/w/Java_Edition_protocol/Packets (Handshaking, C→S, 0x00); field definitions confirmed in node-minecraft-protocol/src/client/setProtocol.js:21-26 (protocolVersion, serverHost, serverPort, nextState); packet schema in minecraft-data/data/pc/1.8/protocol.json (packet_set_protocol).
Intent / Next State values
| Value | Meaning | Introduced |
|---|---|---|
1 |
Status ping | 1.7 |
2 |
Login (play) | 1.7 |
3 |
Transfer login | 1.20.5 (protocol 766) |
Intent 3 indicates the client arrived via a Transfer packet from another server (cookie-based server transfer introduced in 1.20.5). Both 2 and 3 transition into the Login protocol state; 3 lets the receiving server decide to accept or reject transfers. BungeeCord sets transferred = true at InitialHandler.java:391 when intent is 3.
SRV record resolution
Before opening the TCP connection the client queries _minecraft._tcp.<domain> for a DNS SRV record. If found, the resolved hostname and port replace the raw user input:
options.hostandoptions.portare updated from the SRV response beforesetSocket()is called.- The Server Address field sent in the Handshake packet is the SRV-resolved hostname, not the original domain the user typed.
Source: node-minecraft-protocol/src/client/tcp_dns.js:21-33
// tcp_dns.js:21-33
dns.resolveSrv('_minecraft._tcp.' + options.host, (err, addresses) => {
if (addresses && addresses.length > 0) {
options.host = addresses[0].name // <-- replaces host
options.port = addresses[0].port // <-- replaces port
client.setSocket(net.connect(addresses[0].port, addresses[0].name))
}
})
Practical consequence: if play.example.com has an SRV record pointing to mc1.example.com:19132, the Handshake's Server Address field will contain mc1.example.com, not play.example.com. Proxies doing virtual-host routing must be SRV-aware.
BungeeCord also strips a trailing . that DNS resolvers sometimes append: InitialHandler.java:362-365.
Server Address field overloads
The Server Address field is a plain UTF-8 string with a 255-character limit. Three separate systems have abused it by appending NUL-delimited (\0) data after the hostname.
Parse order matters
raw Server Address field
│
├── contains \0? ──yes──▶ split on first \0
│ left = actual hostname (virtualHost)
│ right = extra payload (FML tag or BC forward data)
└── no ──▶ use as-is
BungeeCord implements this at InitialHandler.java:355-359:
if ( handshake.getHost().contains( "\0" ) ) {
String[] split = handshake.getHost().split( "\0", 2 );
handshake.setHost( split[0] );
extraDataInHandshake = "\0" + split[1]; // re-prepends \0
}
Overload 1 — Forge / FML client detection
Forge appends a NUL-delimited marker to signal that the connecting client has Forge loaded. The marker sits immediately after the hostname, before any BungeeCord forwarding data.
| Era | Marker (literal) | Forge versions |
|---|---|---|
| FML / Legacy (1.7–1.12.2) | \0FML\0 |
Forge for MC 1.7.x – 1.12.x |
| FML2 (1.13+) | \0FML2\0 |
Forge for MC 1.13+ |
| Modern (1.20.2+) | \0FORGE or \0FORGEn |
NeoForge / Forge 1.20.2+ |
BungeeCord source defines only \0FML\0 as FML_HANDSHAKE_TOKEN (ForgeConstants.java:20). The comment in InitialHandler.java:351-354 reads:
"Starting with FML 1.8, a
\0FML\0token is appended to the handshake. This interferes with Bungee's IP forwarding, so we detect it, and remove it from the host string, for now. We know FML appends\00FML\00. However, we need to also consider that other systems might add their own data to the end of the string. So, we just take everything from the\0character and save it for later."
The Forge handler checks extraDataInHandshake.contains(ForgeConstants.FML_HANDSHAKE_TOKEN) to set fmlTokenInHandshake (ForgeClientHandler.java:41-46, UserConnection.java).
Wire layout (Forge client, no proxy):
hostname\0FML\0
Example: mc.example.com\0FML\0
The FML-tag-breaks-naive-proxies gotcha: Any proxy or server plugin that does a naive split on \0 expecting exactly the BungeeCord forwarding format will get confused when a Forge client connects without going through BungeeCord. The first field after the split is FML (or FML2, FML3), not a client IP address. Always check whether the first extra token is a known FML marker before attempting to parse it as IP-forwarding data.
Overload 2 — BungeeCord legacy IP forwarding
When ip_forward: true in BungeeCord's config, BungeeCord rewrites the Handshake packet it sends to the backend server to embed the real client IP, UUID, and texture properties. This happens in ServerConnector.java:114-123.
Authoritative source — ServerConnector.java:116-121:
String newHost = copiedHandshake.getHost() + "\00" + AddressUtil.sanitizeAddress( user.getAddress() ) + "\00" + user.getUUID();
LoginResult profile = user.getPendingConnection().getLoginProfile();
if ( profile != null && profile.getProperties() != null && profile.getProperties().length > 0 ) {
newHost += "\00" + LoginResult.GSON.toJson( profile.getProperties() );
}
user.getUUID() returns the UUID without dashes (InitialHandler.java:801-803):
public String getUUID() {
return uniqueId.toString().replace( "-", "" );
}
Exact wire layout:
<hostname>\0<clientIP>\0<uuidNoDashes>\0<propertiesJSON>
| Segment | Content | Notes |
|---|---|---|
<hostname> |
Original virtual host (pre-stripped of FML tag) | e.g. mc.example.com |
\0 |
NUL byte (0x00) | delimiter |
<clientIP> |
AddressUtil.sanitizeAddress() output |
IPv4 dotted-decimal or IPv6 without scope ID |
\0 |
NUL byte | delimiter |
<uuidNoDashes> |
Player UUID, 32 hex chars, no dashes | e.g. 069a79f444e94726a5befca90e38aaf5 |
\0 |
NUL byte | delimiter (only present when properties follow) |
<propertiesJSON> |
LoginResult.GSON.toJson(profile.getProperties()) |
Texture properties array; omitted entirely if profile == null or properties array is empty |
Full example with textures:
mc.example.com\0203.0.113.42\0069a79f444e94726a5befca90e38aaf5\0[{"name":"textures","value":"eyJ0...","signature":"abc..."}]
Full example without textures (offline-mode or missing profile):
mc.example.com\0203.0.113.42\0069a79f444e94726a5befca90e38aaf5
Backend servers (e.g. Paper with settings.bungeecord: true) re-parse this field on login to reconstruct the real player identity.
Note on FML + BungeeCord interaction: When ip_forward is enabled, BungeeCord does NOT re-append the extraDataInHandshake (the FML tag). The code path is mutually exclusive (ServerConnector.java:124-128):
} else if ( !user.getExtraDataInHandshake().isEmpty() ) {
// Only restore the extra data if IP forwarding is off.
// TODO: Add support for this data with IP forwarding.
copiedHandshake.setHost( copiedHandshake.getHost() + user.getExtraDataInHandshake() );
}
This means: with BungeeCord ip_forward enabled, Forge mod detection via the handshake tag is broken at the backend. Confirmed in BungeeCord HEAD (commit f56d37f, 2026-06-18): ServerConnector.java:124–128 still has the TODO: Add support for this data with IP forwarding comment and the else if branch is unchanged.
Overload 3 — Velocity modern forwarding (does NOT use this field)
Velocity's modern forwarding mode does not overload the Server Address field. Instead it uses a Login Plugin Message exchange during the Login state on the backend connection (channel velocity:player_info). See proxy-forwarding/velocity-modern.md for details.
Velocity's legacy/BungeeCord-compat mode (ip-forwarding-mode = LEGACY) uses the same \0-delimited format described in Overload 2 above, for compatibility with servers that already support BungeeCord forwarding.
State-machine diagram
flowchart LR
C["Client"] -->|"Handshake\n(0x00)"| P["Proxy / Server"]
P -->|"Intent=1"| S["Status state"]
P -->|"Intent=2"| L["Login state"]
P -->|"Intent=3 (1.20.5+)"| L
S --> Ping["Status / Ping\n(see 04-status-ping.md)"]
L --> Login["Login sequence\n(see 05-login.md)"]
Version history summary
| Version | Change |
|---|---|
| 1.7.2 | Handshake packet introduced in this form; fields stable since |
| 1.7–1.12.2 | Forge appends \0FML\0 for modded clients |
| 1.8+ | BungeeCord ip_forward writes host\0ip\0uuid[\0props] to backends |
| 1.13+ | Forge marker changes to a different token (exact string unconfirmed from proxy sources; see table above) |
| 1.20.5 (protocol 766) | Intent value 3 (Transfer) added |
Key sources
| Source | What it confirms |
|---|---|
BungeeCord/proxy/…/connection/InitialHandler.java:355-359 |
FML tag stripping logic, extraDataInHandshake |
BungeeCord/proxy/…/connection/InitialHandler.java:801-803 |
UUID stripped of dashes |
BungeeCord/proxy/…/ServerConnector.java:114-123 |
Authoritative ip_forward write path |
BungeeCord/proxy/…/forge/ForgeConstants.java:20 |
FML_HANDSHAKE_TOKEN = "\0FML\0" |
node-minecraft-protocol/src/client/setProtocol.js:21-26 |
Client-side field names; tagHost extension point |
node-minecraft-protocol/src/client/tcp_dns.js:21-33 |
SRV resolution overwrites host/port before connect |
minecraft-data/data/pc/1.8/protocol.json |
packet_set_protocol field schema (varint, string, u16, varint) |
| minecraft.wiki/w/Java_Edition_protocol/Packets | Field names, String(255) limit, Intent=3 description |