Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chat and signing

Verified against Minecraft 26.2 · Part IX · A player presses T, types a line and hits enter: the message is signed on the way out, taken apart on the way in, and verified again by every client that draws it.

A player presses T, types hey and hits enter. Before the line leaves the machine, ChatScreen.normalizeChatMessage has squeezed the whitespace and cut it to 256 characters, and ClientPacketListener.sendChat has taken a timestamp, a random salt and the signatures of the twenty messages the player most recently saw, and signed all of it with a key Mojang issued to that account. The server pulls the packet apart on the Netty thread, hands the cryptography to the Server thread, filters it, decorates it, broadcasts it — and every receiving client verifies the signature again before drawing a character. Every one of those steps can say no, and no does not mean the same thing twice. Forge a signature and you stay connected: you get a red line and your chain dies, so everything else you say this session fails too. Miscount which messages you have seen — a number nothing in the game shows you — and the server closes the connection mid-sentence. The bookkeeping is defended harder than the cryptography is, and that is the right way round.

The cast

classwhat it decidesthread
ServerGamePacketListenerImplthe order the checks run in, and which failure closes the connectionNetty for the window, the characters and the chat-visibility refusal, Server for everything after
LastSeenMessagesValidatorwhether the client’s twenty-slot acknowledgement still matches the server’s mirrorNetty, inside a lock on itself
SignedMessageChain.Decoderwhether this message continues the sender’s chain — and whether the chain survives the answerServer
PlayerChatMessagewhat a signature covers: the chain link, the content, the timestamp, the salt, the windowwherever a message is built
MessageSignatureCachewhich of those signatures travel as a small index instead of 256 bytesboth sides, one 128-slot cache each
PlayerList · OutgoingChatMessagewho gets a copy, and whether it goes as a signed message or a disguised oneServer
SignedMessageValidator.KeyBasedwhether the receiving client believes the sender said thisRender
ChatTrustLevelsecure, modified or not secure — the tag drawn beside the lineRender

A message is not the text you see

A message on the wire is a PlayerChatMessage: a SignedMessageLink saying where in the sender’s chain it sits, a MessageSignature, a SignedMessageBody of exactly four fields, and — optionally — a Component to display instead of the signed string. That Component is Part II’s subject (text components); all this page needs from it is that it is a different object from the signed text and that the signature does not cover it. Vanilla’s decorator never produces one: MinecraftServer.getChatDecorator is hard-coded to ChatDecorator.PLAIN, so PlayerChatMessage.withUnsignedContent always finds the decorated copy equal to the original and drops it. But vanilla sends unsigned content by another road entirely — MessageArgument.resolveChatMessage, the message argument behind /msg, /say and /tell, sets it on every message it resolves, because it has expanded the entity selectors in the text. Type a selector into a whisper and the recipient sees a string the signature does not cover.

One line, typed and delivered

sequenceDiagram
    participant CScr as ChatScreen
    participant CPL as ClientPacketListener
    participant SGPL as ServerGamePacketListenerImpl
    participant PL as PlayerList
    participant RCPL as ClientPacketListener
    participant CLis as ChatListener
    Note over RCPL: RCPL is the recipient's client, CPL the sender's

    CScr->>CPL: whitespace squeezed, cut to 256 characters
    CPL->>CPL: timestamp, salt, the last-seen window, then sign
    CPL->>SGPL: ServerboundChatPacket
    SGPL->>SGPL: Netty thread, apply the last-seen update, check the characters
    Note over SGPL: everything below is a task queued on the Server thread
    SGPL->>SGPL: SignedMessageChain.Decoder.unpack, which verifies the signature
    SGPL->>SGPL: start the filter, decorate at once, join them in a FutureChain
    SGPL->>PL: broadcastChatMessage, bound to ChatType.CHAT
    PL->>RCPL: ClientboundPlayerChatPacket, signatures packed to cache ids
    RCPL->>RCPL: check the global index, unpack the cache ids, verify the signature
    RCPL->>CLis: handlePlayerChatMessage, trust level, blocklist, delay queue
    CLis->>RCPL: markMessageAsProcessed
    RCPL->>SGPL: ServerboundChatAckPacket, once the offset passes 64

Four things in that picture are worth naming before the checks are.

The client signs the conversation, not just the sentence. The window it signs is the window it now treats as acknowledged, so the signature binds the context the sender had in front of them — which is what makes a report show what a message was a reply to.

The hop is deliberate. ServerGamePacketListenerImpl.handleChat never calls the usual same-thread guard: the window and the character check run on the Netty thread, and only then does ServerGamePacketListenerImpl.tryHandleChat post the rest to the server. That posted task, and the FutureChain continuation that joins the text filter to it, drain with every other queued server task — so a slow filter service delays delivery by however many ticks it takes (the server tick).

Decoration is not sequenced after filtering. The handler starts the filter future, decorates immediately and synchronously, and only then registers the continuation that joins the two. A decorator never sees filtered text.

Broadcast is per recipient, and gated in three places — two before the message is built for that player, and ServerPlayer.shouldFilterMessageTo inside it. PlayerList.broadcastChatMessage logs the line — marked Not Secure by PlayerList.verifyChatTrusted if it has no signature or has expired — and then offers it to every player without testing anything. ServerPlayer drops it unless that player’s setting is ChatVisiblity.FULL; OutgoingChatMessage.Player applies the per-recipient filter mask and skips a copy that was filtered away entirely, telling the sender so. A message whose sender is Util.NIL_UUID is a system message and leaves as an unsigned, unreportable ClientboundDisguisedChatPacket instead.

Three ways to say no

flowchart TD
    P["ServerboundChatPacket, on the Netty thread"] --> W{"last-seen window agrees"}
    W -- no --> X1["connection closed: chat_validation_failed"]
    W -- yes --> C{"every character allowed"}
    C -- no --> X2["connection closed: illegal_characters"]
    C -- yes --> H["queued on the Server thread"]
    H --> S{"SignedMessageChain.Decoder.unpack"}
    S -- "no signature, or key expired" --> M["message dropped, red line to the sender, the next one may still land"]
    S -- "out of order, or signature invalid" --> B["chain broken, every later message this session fails too"]
    S -- "accepted" --> OK["filter, decorate, broadcast"]

Those three endings are the whole vocabulary of failure here, and every check in the next section lands on exactly one of them.

The message dies alone: it is dropped, the sender usually gets a red system line explaining why, and the next thing they send is judged on its own merits. The chain dies for the session: SignedMessageChain clears the link it was going to advance, and from then on no unpack can succeed — the chain broken error if the message is otherwise well-formed, and a missing-key or expired-key error before that if it is not. Only a new session key, announced with ServerboundChatSessionUpdatePacket, restores it. The connection dies immediately, and the player is back at the multiplayer list.

Every check, and what it costs

The first fifteen rows are the server treating the client as the adversary. The last three are the client treating the server as one — the same design mirrored, because a server can lie about who said what at least as easily as a client can.

the checkwhat it catcheswhat dies
LastSeenMessagesValidator.applyOffset, from a chat packet or a bare ServerboundChatAckPacketa client advancing its window past messages the server has not sent itconnection
LastSeenMessagesValidator.applyUpdate, the acknowledged bitsa bit set longer than twenty, one naming a slot the server does not hold, or one un-acknowledging a slot already acknowledgedconnection
LastSeenMessages.Update.verifyChecksumthe two sides holding different signatures in slots whose bits agree — a desync the crypto would otherwise report as a bad signatureconnection, unless the client sent LastSeenMessages.Update.IGNORE_CHECKSUM
ServerGamePacketListenerImpl.isChatMessageIllegal, over StringUtil.isAllowedChatCharactersection signs and control characters — formatting injected into everyone else’s chatconnection
ServerPlayer.getChatVisibility, non-commands onlya player who turned chat off and sent a line anywaymessage, with a red chat.disabled.options back to the sender
SignedMessageChain.Decoder.unpack, no signature presentan unsigned message once a chat session exists — unconditionally, whatever MinecraftServer.enforceSecureProfile says, which governs only the decoder used before one doesmessage
the same, ProfilePublicKey.Data.hasExpireda session key past its expiry still being used to signmessage
the same, a timestamp before the last accepted onea replayed or reordered message from this senderchain
the same, PlayerChatMessage.verifycontent, timestamp, salt or window that do not match the signature — a forgery, or a proxy editing text in flightchain
ServerGamePacketListenerImpl.collectSignedArguments, an unknown argument namea client signing arguments of a command the server’s own parse does not havechain, broken explicitly
the same, a signable argument with no signaturesignatures stripped from some arguments of a signed commandmessage — the chain is left intact
ServerGamePacketListenerImpl.performUnsignedChatCommanda signable command sent down the plain command packet with its signatures removedmessage, and only when MinecraftServer.enforceSecureProfile is on
ServerGamePacketListenerImpl.detectRateSpam, a TickThrottler per playerflooding: each message costs 20 and one point decays per tickconnection, except for operators and the singleplayer host
ServerGamePacketListenerImpl.sendPlayerChatMessage, via LastSeenMessagesValidator.trackedMessagesCounta client that is sent signed messages and never acknowledges themconnection, past 4,096 pending
ServerGamePacketListenerImpl.handleChatSessionUpdatea key that expires earlier than the one it replaces, or one RemoteChatSession.Data.validate cannot trace to Mojang’s services keyconnection
ClientPacketListener.handlePlayerChat, the global indexa server dropping, duplicating or reordering messages beneath the player, which would falsify any report drawn from the logconnection
MessageSignature.Packed.unpack against the client’s cachea server naming a cached signature the client has never heldconnection
SignedMessageValidator.KeyBased — expired key, failed signature, or a link that is not SignedMessageLink.isDescendantOf the lasta server inventing lines in another player’s namechain, latched: the validator never returns to valid

Two rows want a sentence more. The checksum is the only check in the table a client may decline: LastSeenMessages.Update.verifyChecksum passes anything when the byte is zero, and a real checksum that computes to zero is bumped to one so it can never be mistaken for the opt-out — though the vanilla client never opts out, because LastSeenMessagesTracker.generateAndApplyUpdate always computes one. And chain broken is a latch on both sides: the server’s SignedMessageChain and the receiving client’s SignedMessageValidator.KeyBased both refuse everything afterwards, so one bad signature costs a sender their voice until a key rotation, not one line.

Why losing the window is worse than losing the signature

The asymmetry looks backwards until you notice what the window is for. The last-seen list is signed in full but sent as MessageSignature.Packed indices into a cache both sides maintain identically. If those caches ever diverge, the receiver reconstructs a different SignedMessageBody, and every signature after that fails for a reason no cryptographic error message can explain. There is no recovery from inside: the state is shared, and half of it is wrong.

So the game ends the connection at the first sign of that divergence, and LastSeenMessagesValidator is written to be suspicious. It rejects an acknowledgement of a slot it does not hold, an un-acknowledgement of a slot it already acknowledged, a negative offset, an offset larger than the number of tracked messages outside the window it has actually sent, and a bit set wider than the window; a checksum mismatch on top of all that says the client and server must have desynced in as many words.

A bad signature is the opposite kind of problem: local, provable and attributable. One sender is misbehaving, everyone else’s conversation is intact, and the proportionate answer is to stop trusting that sender — not to end a session for every other player in the room.

Sixty-four — the acknowledgement offset a client may accumulate before ClientPacketListener.markMessageAsProcessed sends a bare ServerboundChatAckPacket unprompted. That packet exists so that a player who only listens never reaches the server’s 4,096 pending messages and gets dropped for saying nothing all evening.

What the signature covers

PlayerChatMessage.updateSignature feeds the signer a version constant, then the link — sender id, session id, index — then the body: the salt, the timestamp in seconds, the length of the content, the content bytes, the count of last-seen signatures and each one’s raw bytes. Not fed to it: the decorated Component, the FilterMask, the ChatType.Bound that supplies the someone said wrapper, and the global index the receiving client checks. A server is free to change any of those. The signature is over what the player typed and what they had seen when they typed it, and nothing else.

That gap is what ChatTrustLevel exists to expose, and it tests for it crudely on purpose. ChatTrustLevel.evaluate calls a message modified the moment the rendered string does not contain the signed string — the limb that catches a server rewriting what someone said. Only if that passes does it look at style, and only inside the unsigned copy — which vanilla does send, for any command message carrying a selector. A message with no signature at all, or one older than seven minutes, is not secure instead. The tag is normally all that happens; with Options.onlyShowSecureChat on, a not-secure message is discarded rather than drawn — that test runs first, and Minecraft.isBlocked and Minecraft.isFriendOnlyRestricted can swallow what survives it.

The session key is signed one level up. ProfilePublicKey.Data carries an expiry, the public key and a signature over the profile id, the expiry in milliseconds and the encoded key, checked against Mojang’s services key. The receiving client allows ProfilePublicKey.EXPIRY_GRACE_PERIOD — eight hours — that the signing chain on the server does not.

Commands: one signature per argument

ClientPacketListener.sendCommand parses the command locally and builds a SignableCommand. If nothing in it needs signing it sends ServerboundChatCommandPacket, which carries the string and nothing else. If something does, it sends ServerboundChatCommandSignedPacket with ArgumentSignatures: one signature per argument, each consuming its own chain index, all of them sharing one timestamp, salt and window. Signable means the argument type implements SignedArgument, and in 26.2 exactly one type does — MessageArgument, behind the message-shaped commands. Its MessageArgument.Message.toComponent is also the one place chat text has its selectors expanded — each MessageArgument.Part resolved by EntitySelector.joinNames, behind a permission — which is why /say @a names people and a chat line saying the same thing does not.

The server re-parses its own copy and looks each signature up by argument name, which is where two rows of the table above come from: a name its parse does not have breaks the chain outright, while a signable argument the client left unsigned only fails the command. Both sides cap the shape of the packet at ArgumentSignatures.MAX_ARGUMENT_COUNT — eight — and ArgumentSignatures.MAX_ARGUMENT_NAME_LENGTH, sixteen. Commands run against their own TickThrottler, separate from chat’s and with its own threshold. A command message that ends up with no signed argument is broadcast as a ClientboundDisguisedChatPacket: chat-type decorated, unsigned, unreportable.

Questions players ask

What actually makes the “Not Secure” tag appear? No signature, or a timestamp more than seven minutes old by the receiving client’s clock. The server calls the same message stale after five, and logs it as Not Secure there too. Two machines whose clocks are a few minutes apart will flag perfectly honest messages, and the server says so in its log.

Why does a custom font not flag every line on my server? Because the style test only looks inside the unsigned, decorated copy — and vanilla never sends one. The font check is dead on a vanilla server and live on a server that decorates. A player’s own lines on an integrated server skip both tests and are secure by definition.

Can a server delete a message from my chat? It has the packet for it: ClientboundDeleteChatPacket is registered and handled, and the handler will pull the line out of the player’s own chat-delay queue if their chatDelay option means it has not been drawn yet. Nothing in the game constructs it.

Why can I report some lines and not others? LoggedChatMessage.canReport needs a signature from the player being reported, and system messages, disguised command output and anything a broken chain swallowed carry none. What a report uploads is the signed material — index, session id, timestamp, salt, the last-seen signatures and the signed content — so it can be re-verified independently, with ChatReportContextBuilder.collectAllContext walking the last-seen links backwards for the conversation around it.

Where does my signing key live? Nowhere, in a shipped client: AccountProfileKeyPairManager writes the key file only when SharedConstants.IS_RUNNING_IN_IDE and deletes it otherwise, so each launch re-fetches from the account service — and only if ClientboundLoginPacket.onlineMode said the server was in online mode.

Why can I not chat here even though nothing is wrong? ChatAbilities and ChatRestriction are a client-side layer the server has no part in: game options, launcher policy and the account profile each strip permissions independently, and what survives decides whether this client will send messages, send commands, or accept player or system messages at all. ChatVisiblity is only the sliver of that the server is told about, and it has three values, not two — ChatVisiblity.HIDDEN still lets action-bar text through.

And if somebody’s session key fails validation? Which side notices decides the cost. On the server it closes the connection. On another client, ClientPacketListener.initializeChatSession merely calls PlayerInfo.clearChatSession, and that player’s lines arrive unsigned — taken and tagged insecure by SignedMessageValidator.ACCEPT_UNSIGNED, or refused by SignedMessageValidator.REJECT_ALL where secure profiles are enforced.

Where to look

ChatScreen.normalizeChatMessage · ClientPacketListener.sendChat · LastSeenMessagesTracker.generateAndApplyUpdate · ServerGamePacketListenerImpl.handleChat · ServerGamePacketListenerImpl.unpackAndApplyLastSeen · LastSeenMessagesValidator.applyUpdate · ServerGamePacketListenerImpl.tryHandleChat · SignedMessageChain.Decoder · PlayerChatMessage.updateSignature · PlayerList.broadcastChatMessage · OutgoingChatMessage.create · ServerGamePacketListenerImpl.sendPlayerChatMessage · ClientPacketListener.handlePlayerChat · MessageSignatureCache.push · SignedMessageValidator.KeyBased · ChatListener.handlePlayerChatMessage · ChatTrustLevel.evaluate · ClientPacketListener.markMessageAsProcessed · ChatReportContextBuilder.collectAllContext


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.