MCBE
OverviewTransportEncryption

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

sequence (RakNet transport)
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 complete

Over 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).

handshake JWT
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

derivation
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

per-batch
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.

chain JSON — new 1.26.x envelope
{
  "Certificate": "{\"chain\":[\"<jwt>\",...]}",   // stringified legacy chain
  "AuthenticationType": 1 | 2,   // 1 guest (rejected by auth servers), 2 self-signed
  "Token": "<OIDC multiplayer JWT>"
}
chain JSON — legacy envelope
{ "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
multiplayer token (OIDC) claims
{ "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:

client auth chain
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 JSON

Server 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.

batch layout recap
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)