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.3 · 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 holds the sender’s session.

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 messages the player saw among the last twenty signed ones it was sent, 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 that holds the sender’s session 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, and Server for the pending count — 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

Two packets carry all of this, and the page names them once here: a line leaves its author in a ServerboundChatPacket and reaches everybody else in a ClientboundPlayerChatPacket, which is the one packet in the game whose contents a recipient checks a player’s signature over before drawing.

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 — that method keeps a copy only when it differs from the signed text. Vanilla still reaches it by another road: MessageArgument.resolveChatMessage, behind the message-shaped commands, hands it the resolved text, which differs exactly when a selector expanded. Type a selector into a whisper and the recipient sees a string the signature does not cover — the security half of a mechanism the Commands section below picks up.

One line, typed and delivered

sequenceDiagram
    box transparent the sender's client
    participant CScr as ChatScreen
    participant CPL as ClientPacketListener
    end
    box transparent the server
    participant SGPL as ServerGamePacket<br/>ListenerImpl
    participant PL as PlayerList
    end
    box transparent every recipient's client
    participant RCPL as ClientPacketListener
    participant CLis as ChatListener
    end

    CScr->>CScr: normalizeChatMessage squeezes the whitespace, cuts to 256
    CScr->>CPL: sendChat
    CPL->>CPL: a timestamp, a salt, the last-seen window, then the signature
    CPL->>SGPL: ServerboundChatPacket
    SGPL->>SGPL: Netty thread, apply the last-seen update, check the characters
    rect rgba(0, 0, 0, 0.04)
        Note over SGPL,PL: the rest of the server's work, all of it on the Server thread
        SGPL->>SGPL: SignedMessageChain.<br/>Decoder.unpack, the signature check
        SGPL->>SGPL: the filter starts, the decoration is immediate, a FutureChain joins them
        SGPL->>PL: broadcastChatMessage, bound to ChatType.CHAT
    end
    PL->>RCPL: ClientboundPlayerChatPacket, cached last-seen signatures sent as ids
    RCPL->>RCPL: the global index, then the cache ids, then the signature
    RCPL->>CLis: handlePlayerChatMessage: the delay queue, then trust level and blocklist
    CLis->>RCPL: markMessageAsProcessed
    RCPL->>SGPL: ServerboundChatAckPacket, once the offset passes 64

One line’s whole journey, three machines in three boxes: the two ClientPacketListener lanes are the sender’s and every recipient’s, and the same signature is checked once in the middle box and again in the right-hand one. The shaded band is the server’s work on the Server thread, which ends in the arrow that leaves it.

Five 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 connection has what that guard normally does): 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).

The filter is an HTTP service, and usually there is none. TextFilter is the per-player interface every chat line — and every signed command message, book page, sign line and anvil name — passes through, and MinecraftServer.createTextFilterForPlayer hands out TextFilter.DUMMY, which passes everything, unless the server is a dedicated one whose text-filtering-config property names an endpoint. ServerTextFilter.createFromConfig reads that property at startup (starting a server) and builds either a LegacyTextFilter or a PlayerSafetyServiceTextFilter depending on the configured version, each a pool of Chat-Filter-Worker threads posting JSON to that endpoint. What comes back is a FilteredText — the raw string and a FilterMask of which characters were objected to — so the filter is never destructive here and never binary: the mask travels with the message and is applied per recipient, later. Filterable is the same idea frozen into saved data, a raw value beside an optional filtered one.

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 decided in three places. PlayerList.broadcastChatMessage logs the line — marked Not Secure by PlayerList.verifyChatTrusted if it has no signature or has expired — and then asks the sender’s ServerPlayer.shouldFilterMessageTo of each player in turn. ServerPlayer drops the copy unless that player’s setting is ChatVisiblity.FULL; OutgoingChatMessage.Player applies the filter mask to a filtered copy and skips one that was filtered away entirely, and PlayerList tells the sender once if any was. 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, and one way not to ask

Everything above happens to a message that survives. Five of the checks run before the server thread ever sees the packet, and where a refusal lands decides how much of the session it takes with it.

flowchart TD
    P["ServerboundChatPacket"]:::netty --> N{"the window, the characters, chat visibility"}:::netty
    N -- "the window, or a character" --> XC["the connection dies"]
    N -- "chat turned off" --> XM["the message dies, and the next one may still land"]
    N -- "all five pass" --> S{"SignedMessageChain.<br/>Decoder.unpack"}:::server
    S -- "no signature, or key expired" --> XM
    S -- "out of order, or signature invalid" --> XB["the chain dies, and every later message this session with it"]
    S -- accepted --> OK["filter, decorate, broadcast"]:::server

The five checks on the Netty thread, in green, and the Server thread’s work, in blue, drawn so that the arrows meet at the three things a refusal can kill. The middle terminal is reached from both, which is the picture’s point: where a check runs says nothing about what it costs.

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, or a new connection restores it. The connection dies immediately — through the same funnel as any other fault (the connection) — and the player is back at the multiplayer list.

And there is a fourth way, which is why the flowchart starts where it does. A client can refuse before the server is ever asked. ChatAbilities is a client-side permission set the server has no part in, stripped independently by game options, launcher policy and the account profile, and it decides whether this client will send or accept anything at all (permissions). Nothing below applies to a message that was never sent, and a player in that state sees no error from the server because there was no exchange to fail. ChatVisiblity is the sliver of the same idea the server is told about, and it has three values, not two — ChatVisiblity.HIDDEN still lets action-bar text through.

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 first nine rows are in the order a chat line meets them, and the first five run on the Netty thread, before the message is handed to the server at all: the three window checks inside ServerGamePacketListenerImpl.unpackAndApplyLastSeen, then the character check and the chat-visibility refusal inside ServerGamePacketListenerImpl.tryHandleChat. Four of those five close the connection and the fifth does not, which is the split the flowchart above draws; rows six to nine happen inside the task ServerGamePacketListenerImpl.tryHandleChat posts to the server, and the rate check in the continuation that broadcasts. The rest are other paths: a command, each recipient’s copy, a new session key, and the receiving client.

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: that flag decides what the decoder used before a session exists does with an unsigned message, and separately gates the unsigned-command refusal six rows downmessage
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, one point decays per tick, and the ceiling is twenty per configured second — chat-spam-threshold-seconds, ten by default, so 200 points, or about ten messages sent faster than the decayconnection, except for operators, and on an integrated server for anyone, its threshold being zero
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 new key or a new connection, 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 — twenty LastSeenTrackedEntry slots on the tracker — is signed in full but sent as a bitset over a window the server mirrors, and passed on to other clients as MessageSignature.Packed entries, each an index into a cache both ends keep in step where that cache holds the signature. If the server’s mirror ever disagrees with the client’s window, the server 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 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.

What keeps the two caches identical is that neither end chooses what goes in. MessageSignatureCache.push takes a message’s body and its signature, builds a queue of the last-seen signatures with that signature behind them, and writes them into 128 slots newest-first, shuffling back any displaced entry the new batch did not already contain. It is called in exactly two places, and they are the two ends of one connection: the server as it sends a signed message to that player, and that player’s client as it handles the packet. Same input, same method, same order — which is why an index means the same thing at both ends, and why nothing can put them back in step once they are not.

Sixty-four messages is 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 — a ChatType is a data-pack registry entry and ChatTypeDecoration is the translation key and argument list that turns a message into so-and-so said this, so the phrasing around a line is data and the line is not — 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.

On the receiving client all of that lands in ChatListener, the class the figure at the top of this page ends on. ChatListener.handlePlayerChatMessage records a signed message in the client’s window whether or not it is drawn (a refused one as an empty slot, never acknowledged) and puts the line, with that bookkeeping, in ChatListener.delayedMessageQueue rather than on the screen when the chatDelay option is set — a queue its own tick drains one line at a time, which is why the option is a client-side stagger and not a server one.

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 sends only for a command message whose selector expanded, and that copy has already failed the contains test. 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.

The private half lives nowhere on disk. LocalChatSession holds the pair for as long as a session lasts, and AccountProfileKeyPairManager writes the key file only when SharedConstants.IS_RUNNING_IN_IDE and deletes it otherwise, so a shipped client re-fetches from the account service every launch — and only if ClientboundLoginPacket.onlineMode said the server was in online mode, which is the packet that opens the play phase (protocol phases).

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.3 exactly one type does — MessageArgument, behind the message-shaped commands, and the enumeration of which commands register it belongs to brigadier and commands. MessageArgument is also the one route by which chat text has its selectors expanded, which is how a command message comes to carry unsigned content at all: the expansion itself, its permission and its parse belong to text components. What matters here is only that the expanded string is the one the recipient reads and the signature does not cover it.

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 built the same way from command-spam-threshold-seconds, which also defaults to ten — so a player has two independent budgets and can spend both. A command message sent down the plain command packet, where secure profiles are not enforced, is broadcast as a ClientboundDisguisedChatPacket: chat-type decorated, unsigned, unreportable.

Questions players ask

What 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 expired 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 copy, and vanilla’s decorator never produces one. For ordinary chat the font check is therefore dead, and it comes alive only on a server that decorates. A player’s own lines on an integrated server skip both tests and are secure by definition.

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.

Does dying clear a broken chain? No, and nothing short of a new key or a new connection does. The chain decoder lives on ServerGamePacketListenerImpl, and a respawn builds a new ServerPlayer while keeping the same listener — PlayerList.respawn copies the connection across and ServerPlayer.restoreFrom copies the chat session with it — so the broken link survives death and the other respawn that replaces the player object, leaving the End. Within one connection only ServerGamePacketListenerImpl.handleChatSessionUpdate, given a different key, installs a fresh decoder.

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 calls PlayerInfo.clearChatSession, and that player’s lines are treated as unsigned — the signature stripped by SignedMessageValidator.ACCEPT_UNSIGNED and the line tagged not secure, or refused by SignedMessageValidator.REJECT_ALL where secure profiles are enforced.

Where to look

Follow one message, in the order it travels. ClientPacketListener.sendChat is where a line becomes a signed object, and LastSeenMessagesTracker.generateAndApplyUpdate beside it is the window being committed to. ServerGamePacketListenerImpl.handleChat is the receiving end, and it is short: the two methods it calls, ServerGamePacketListenerImpl.unpackAndApplyLastSeen and ServerGamePacketListenerImpl.tryHandleChat, are the five Netty-thread checks in the table, in order. Then PlayerList.broadcastChatMessage for the per-recipient fan-out and ClientPacketListener.handlePlayerChat for the far end doing it all again in reverse.

Two classes are the argument rather than the route. LastSeenMessagesValidator is a short file of suspicion and is worth reading in full, because every rejection in it is a connection close. SignedMessageChain is the other, and the one line in SignedMessageChain.Decoder.setChainBroken that drops the next link is what chain broken means.

Three doors this page only points at: PlayerChatMessage.updateSignature, the exact byte order the signature covers; ChatTrustLevel.evaluate, the crude contains test behind the tag; and ChatReportContextBuilder.collectAllContext, which walks the last-seen links backwards and is the reason the window is signed at all.


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