Encryption & Login
How a 1.26.51 connection goes from plaintext UDP to an AES-256-CTR stream, and how clients prove who they are.
Login flow
C → S RequestNetworkSettings plaintext, uncompressed
S → C NetworkSettings compression enabled after this
C → S Login compressed, plaintext
S → C ServerToClientHandshake compressed, plaintext (flushed before enabling)
── both sides enable AES-256-CTR ──
C → S ClientToServerHandshake first encrypted packet
S → C PlayStatus(LoginSuccess)
S → C ResourcePacksInfo
C → S ClientCacheStatus · ResourcePackClientResponse(Downloading…)
S → C ResourcePackDataInfo / ChunkData (download)
S → C ResourcePackStack
C → S ResourcePackClientResponse(ResourcePackStackFinished)
S → C DimensionData · JigsawStructureData · VoxelShapes · StartGame · ItemRegistry
C → S RequestChunkRadius
S → C ChunkRadiusUpdated · PlayStatus(PlayerSpawn) · CreativeContent
C → S SetLocalPlayerAsInitialised spawn completeOver NetherNet the picture is simpler: DTLS encrypts the transport, so the game-layer handshake packets are not used at all and the 0xFE batch header is dropped. Servers that support NetherNet must therefore accept a Login that arrives without the encryption dance — the vanilla client answers ServerToClientHandshake in plaintext there, which is why implementations skip the packet on that transport.
Encryption handshake
ServerToClientHandshake (packet 3) carries a single raw JWT. The client verifies it with the key named in its own header, derives the shared secret, enables encryption and answers with the empty ClientToServerHandshake (packet 4).
header { "alg": "ES384",
"x5u": "<base64 SPKI DER of the server's ECDSA P-384 public key>" }
payload { "salt": "<base64 of 16 random server bytes>" }
signature ES384 with the x5u key
// the client's ECDH key comes from the login data: the identity public key
// (legacy chain) or the cpk claim (multiplayer token)Key derivation & cipher
sharedSecret = ECDH(serverPrivateKey, clientIdentityPublicKey) // secp384r1, 48 bytes key = SHA-256( salt || sharedSecret ) // 32 bytes, one key both ways cipher = AES-256-CTR iv = key[0..12] || 00 00 00 02 // GCM-style counter, no tag
The curve is P-384 (secp384r1) — not P-256. The salt is 16 random bytes generated per connection by the server. Both directions use the same key; only the send counter differs per side.
Batch checksum
sum = SHA-256( sendCounter(uint64 LE) || payload || key )[0..8] // payload = everything after the 0xFE header byte (including prior checksums, // uncompressed form); sendCounter increments per direction per batch // the 8-byte sum is appended to the payload, then the whole thing (after 0xFE) // is CTR-encrypted
Login packet
Login (packet 1) carries a big-endian int32 protocol version and a varuint32-length-prefixed connection request blob: int32 LE chain length + chain JSON, int32 LE token length + token JWT.
{
"Certificate": "{\"chain\":[\"<jwt>\",...]}", // stringified legacy chain
"AuthenticationType": 1 | 2, // 1 guest (rejected by auth servers), 2 self-signed
"Token": "<OIDC multiplayer JWT>"
}{ "chain": [ "<identity JWT>", "<mojang JWT>", "<client JWT>" ] }
token 0 self-signed, header x5u = client identity key, payload:
extraData + identityPublicKey + certificateAuthority
token 1 issuer "Mojang", signed by Mojang's ECDSA P-384 root key;
its identityPublicKey claim keys token 2
token 2 issuer "Mojang", payload extraData + identityPublicKey
extraData = { XUID, identity (UUID), displayName, titleId }
// a single-token chain = offline/self-signed client
// all chain JWTs are ES384{ "ipt": "PlayFab", // identity provider type
"mid": "<PlayFab master id>", // MCID / PFID
"tid": "20CA2", // PlayFab title (retail)
"cpk": "<base64 SPKI DER>", // client public key: ECDH + JWT signing
"xid": "<XUID>", "xname": "<gamertag>", "leguuid": "<offline uuid>" }
aud "api://auth-minecraft-services/multiplayer"
// servers verify against the authorization service's JWKS
// (Microsoft encodes x5t as hex — patch before verifying!)Xbox Live & tokens
The client-side auth stack (as implemented by gophertunnel's auth package and the go-xsapi / go-playfab libraries) builds the login material like this:
1. MSA OAuth2 device token (per-platform title/client ids)
2. XASD device token device.auth.xboxlive.com
3. SISU session → XSTS token xsts.auth.xboxlive.com (XBLToken)
4. PlayFab LoginWithXbox <titleId>.playfabapi.com/Client/LoginWithXbox
XboxToken: "XBL3.0 x=<userhash>;<token>" → PlayFabId + SessionTicket
5. Minecraft authorization service <serviceUri>/api/v1.0/session/start
→ MCToken JWT (Authorization header "MCToken <jwt>")
6. POST /api/v1.0/multiplayer/session/start { "publicKey": "<base64 SPKI>" }
→ signedToken = the key-bound multiplayer token used in Login.Token
// legacy path instead: POST multiplayer.minecraft.net/authentication
// { "identityPublicKey": "<connection key>" } → the whole chain JSONServer side, a session counts as authenticated when the legacy chain verifies against the Mojang root key or the OIDC token carries a non-empty XUID; gophertunnel derives the player UUID from the XUID as an MD5 name-based (v3) UUID over "pocket-auth-1-xuid:<xuid>". LAN games additionally require the discovery nonce from the ServerData blob to be echoed in the Login packet's ClientData.
Compression
NetworkSettings picks the algorithm per session; batches below the threshold carry the 0xFF marker and stay raw. ZLib means raw DEFLATE (no zlib wrapper), compressed at level ~6; Snappy is the s2 block-snappy encoding.
0xFE batch header (not encrypted, not compressed) [8-byte checksum] only when encrypted uint8 algorithm marker 0x00 zlib · 0x01 snappy · 0xFF uncompressed [varuint32 len · packet]* packet = varuint32 header (id|sender<<10|target<<12) + body // limits: 812 packets per batch, 16 MiB max decompressed (0 disables compression, // 1 compresses everything)
