<!-- https://minecraftdocs.dev/systems/networking/chat-and-signing — this page as markdown. The book's index: https://minecraftdocs.dev/llms.txt; the whole book in one file: https://minecraftdocs.dev/llms-full.txt -->

# 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

| class | what it decides | thread |
|---|---|---|
| `ServerGamePacketListenerImpl` | the order the checks run in, and which failure closes the connection | Netty for the window, the characters and the chat-visibility refusal, Server for everything after |
| `LastSeenMessagesValidator` | whether the client's twenty-slot acknowledgement still matches the server's mirror | Netty, and Server for the pending count — inside a lock on itself |
| `SignedMessageChain.Decoder` | whether this message continues the sender's chain — and whether the chain survives the answer | Server |
| `PlayerChatMessage` | what a signature covers: the chain link, the content, the timestamp, the salt, the window | wherever a message is built |
| `MessageSignatureCache` | which of those signatures travel as a small index instead of 256 bytes | both sides, one 128-slot cache each |
| `PlayerList` · `OutgoingChatMessage` | who gets a copy, and whether it goes as a signed message or a disguised one | Server |
| `SignedMessageValidator.KeyBased` | whether the receiving client believes the sender said this | Render |
| `ChatTrustLevel` | secure, modified or not secure — the tag drawn beside the line | Render |

## 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](../foundations/text-components#a-component-is-three-things)); 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

```mermaid
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](the-connection#one-packet-there-and-one-back) 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](../server/server-tick#every-packet-since-last-time-in-one-drain)).

**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](../server/starting-a-server#minecraftserverspin-and-the-last-thing-main-does)) 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.

```mermaid
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](the-connection#how-a-connection-dies)) — 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](../commands/permissions#what-the-client-is-allowed-to-believe)).
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 check | what it catches | what dies |
|---|---|---|
| `LastSeenMessagesValidator.applyOffset`, from a chat packet or a bare `ServerboundChatAckPacket` | a client advancing its window past messages the server has not sent it | **connection** |
| `LastSeenMessagesValidator.applyUpdate`, the acknowledged bits | a bit set longer than twenty, one naming a slot the server does not hold, or one un-acknowledging a slot already acknowledged | **connection** |
| `LastSeenMessages.Update.verifyChecksum` | the two sides holding different signatures in slots whose bits agree — a desync the crypto would otherwise report as a bad signature | **connection**, unless the client sent `LastSeenMessages.Update.IGNORE_CHECKSUM` |
| `ServerGamePacketListenerImpl.isChatMessageIllegal`, over `StringUtil.isAllowedChatCharacter` | section signs and control characters — formatting injected into everyone else's chat | **connection** |
| `ServerPlayer.getChatVisibility`, non-commands only | a player who turned chat off and sent a line anyway | **message**, with a red *chat.disabled.options* back to the sender |
| `SignedMessageChain.Decoder.unpack`, no signature present | an 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 down | **message** |
| the same, `ProfilePublicKey.Data.hasExpired` | a session key past its expiry still being used to sign | **message** |
| the same, a timestamp before the last accepted one | a replayed or reordered message from this sender | **chain** |
| the same, `PlayerChatMessage.verify` | content, timestamp, salt or window that do not match the signature — a forgery, or a proxy editing text in flight | **chain** |
| `ServerGamePacketListenerImpl.collectSignedArguments`, an unknown argument name | a client signing arguments of a command the server's own parse does not have | **chain**, broken explicitly |
| the same, a signable argument with no signature | signatures stripped from *some* arguments of a signed command | **message** — the chain is left intact |
| `ServerGamePacketListenerImpl.performUnsignedChatCommand` | a signable command sent down the plain command packet with its signatures removed | **message**, and only when `MinecraftServer.enforceSecureProfile` is on |
| `ServerGamePacketListenerImpl.detectRateSpam`, a `TickThrottler` per player | flooding: 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 decay | **connection**, except for operators, and on an integrated server for anyone, its threshold being zero |
| `ServerGamePacketListenerImpl.sendPlayerChatMessage`, via `LastSeenMessagesValidator.trackedMessagesCount` | a client that is sent signed messages and never acknowledges them | **connection**, past 4,096 pending |
| `ServerGamePacketListenerImpl.handleChatSessionUpdate` | a key that expires *earlier* than the one it replaces, or one `RemoteChatSession.Data.validate` cannot trace to Mojang's services key | **connection** |
| `ClientPacketListener.handlePlayerChat`, the global index | a server dropping, duplicating or reordering messages beneath the player, which would falsify any report drawn from the log | **connection** |
| `MessageSignature.Packed.unpack` against the client's cache | a server naming a cached signature the client has never held | **connection** |
| `SignedMessageValidator.KeyBased` — expired key, failed signature, or a link that is not `SignedMessageLink.isDescendantOf` the last | a server inventing lines in another player's name | **chain**, 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](protocol-phases#play-and-the-way-back)).

## 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](../commands/brigadier-and-commands#signed-arguments-in-one-paragraph).
`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](../foundations/text-components#resolution-what-a-server-does-to-a-component-before-it-sends-it).
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`.*
