Client-Only Mode
In client-only mode, the proxy terminates the client's connection and handles Mojang authentication itself. The backend server must run with online-mode=false because the proxy, not the backend, verifies the player's identity.
This is the mode you need for server networks, packet inspection, and plugin features that interact with players.
When to use it
Use client-only when you need any of these:
- Server switching within a network (moving players between backends without reconnecting)
- Packet inspection or modification by plugins
- Centralized authentication across multiple backends
- Features like limbo handlers, codec filters, or event-driven packet injection
Configuration
A minimal server config file:
domains = ["mc.example.com"]
addresses = ["192.168.1.10:25565"]
proxy_mode = "client_only"2
3
With Docker labels:
labels:
infrarust.domains: "mc.example.com"
infrarust.proxy_mode: "client_only"2
3
Backend must be offline
Your backend Minecraft server must have online-mode=false in its server.properties. The proxy already verified the player's identity, so the backend doesn't need to check again. If the backend has online-mode=true, it asks the proxy for encryption, and the proxy refuses the login right away and logs that the backend is in online mode.
Full example
This uses every option that applies to client-only mode:
name = "survival"
network = "main"
domains = ["survival.mc.example.com", "*.survival.example.com"]
addresses = ["10.0.1.10:25565", "10.0.1.11:25565"]
proxy_mode = "client_only"
send_proxy_protocol = false
max_players = 100
disconnect_message = "Survival server is offline. Try again later."
limbo_handlers = ["motd", "tablist"]
[timeouts]
connect = "3s"
read = "30s"
write = "30s"
[motd.online]
text = "§6Survival - Online"2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Config options
| Option | Type | Default | Description |
|---|---|---|---|
domains | string array | [] | Domains that route to this server. Supports wildcards (*.mc.example.com). Can be empty if the server is only reachable via server switching within a network. |
addresses | string array | required | Backend addresses in host:port format. |
proxy_mode | string | "passthrough" | Set to "client_only". |
network | string | none | Network name. Servers in the same network can switch players between each other. |
send_proxy_protocol | bool | false | Send PROXY protocol header when connecting to the backend. |
max_players | integer | 0 | Maximum players on this server. 0 means unlimited. |
disconnect_message | string | "Server is currently unreachable..." | Message sent to the player when the backend is down. |
limbo_handlers | string array | [] | Plugin IDs for limbo handler chain, executed in order. |
timeouts.connect | duration | "5s" | How long to wait when connecting to the backend. |
timeouts.read | duration | "30s" | Read timeout on the backend connection. During the backend login, the longest silence allowed between two backend packets while nothing waits on the client, and the time the client has to acknowledge the login. |
timeouts.write | duration | "30s" | Write timeout on the backend connection. |
TIP
Duration values use human-readable format: "5s", "30s", "2m", "1h".
How it works
- The proxy reads the client's handshake and login start packets.
- It fires a
PreLoginEvent, giving plugins a chance to deny the connection. - It performs Mojang authentication: sends an
EncryptionRequestwith the proxy's RSA public key, reads theEncryptionResponse, decrypts the shared secret, and verifies the session againstsessionserver.mojang.com. If this fails, it firesOnlineAuthFailedEventand disconnects the client. - It fires a
GameProfileRequestEvent, where plugins may rewrite the profile, then checks bans against the final UUID. A banned player is disconnected while still in the login phase. - It fires
PermissionsSetupEventandLoginEvent; a plugin can still refuse the player here. - It registers the player and fires a
PostLoginEvent. The client is still in the login phase. - It connects to the backend in offline mode with its own handshake (carrying the forwarding data, if any) and login start.
- While the backend runs its login, the proxy relays the backend's login plugin messages to the client and the client's answers back, with their message ids unchanged, so mods that negotiate during the login (Forge, Fabric, or any other login query) see the real client. It answers a
velocity:player_inforequest itself. Login cookie requests (1.20.5+) are relayed the same way. - Once the backend accepts the login, the proxy sends
SetCompression(seecompression_threshold) andLoginSuccesswith the final UUID, username, and skin properties. For 1.20.2+, it waits for the client'sLoginAcknowledgedpacket and both sides move to the Configuration state; older versions go straight to Play. - It enters the session loop, parsing and relaying packets in both directions.
If the backend refuses the login, its reason reaches the client as a login disconnect, unless a plugin redirects the player through KickedFromServerEvent. If the player goes to a limbo instead of a backend, the proxy sends SetCompression and LoginSuccess without waiting for one, and a backend reached later through a server switch gets "not understood" answers to its login plugin messages.
The backend gets timeouts.read (30 seconds by default) between two of its login packets; packets from the client do not extend it. While a relayed message waits for the client's answer, that deadline is suspended: a mod may show the player a prompt first, or drop the connection on purpose to download files. The client gets at most 5 minutes, counted from the first request it has not answered, and then timeouts.read to acknowledge the LoginSuccess the proxy sends (1.20.2 and later). Past any of these limits the player is disconnected. A client answer to a request the client was never sent, such as one a plugin answered itself, is dropped. A backend that asks for encryption is refused at once, since it runs with online-mode=true.
Because the proxy parses every packet, sessions are marked as active. Plugins can inject packets into the stream, and the proxy can move the player to a different backend without dropping the client connection.
Connection lifecycle
Client ──TCP──▶ Infrarust ──TCP──▶ Backend
│
reads handshake + login start
│
PreLoginEvent (plugins can deny)
│
EncryptionRequest ──▶ Client
Client ──▶ EncryptionResponse
│
decrypt shared secret + verify token
verify session with Mojang
│
GameProfileRequestEvent, ban check
PermissionsSetupEvent, LoginEvent (plugins can deny)
PostLoginEvent (plugins notified)
│
handshake + login start ──▶ Backend (offline mode)
│
Backend ──▶ login plugin requests ──▶ Client
Client ──▶ answers ──▶ Backend
│
Backend ──▶ LoginSuccess (consumed)
│
SetCompression + LoginSuccess ──▶ Client
(1.20.2+: waits for LoginAcknowledged)
│
session loop ─────────▶ parsed packets flow
both directions2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Authentication details
The proxy generates an RSA-1024 key pair on startup and reuses it for all connections. During the login sequence, it:
- Sends the DER-encoded public key to the client in an
EncryptionRequest. - Decrypts the client's shared secret and verify token using RSA PKCS#1 v1.5.
- Computes the Minecraft server hash (a non-standard SHA-1 with signed BigInt output).
- Calls
https://sessionserver.mojang.com/session/minecraft/hasJoinedwith the username and server hash. - Receives the player's game profile (UUID, username, skin textures) from Mojang.
- Enables AES/CFB8 encryption on the client connection using the shared secret.
The backend never sees encrypted traffic. The proxy connects to the backend as an offline-mode client.
Server networks
Client-only is the required mode for servers that belong to a network. A network lets you group multiple backends and switch players between them.
name = "hub"
network = "main"
domains = ["network.example.com"]
addresses = ["192.168.1.10:25565"]
proxy_mode = "client_only"
[motd.online]
text = "§6My Network - Hub"2
3
4
5
6
7
8
name = "survival"
network = "main"
addresses = ["192.168.1.11:25565"]
proxy_mode = "client_only"2
3
4
The first server has the domain, so it receives incoming connections. Both servers share the network = "main" value, so the proxy can move players between them.
The survival server has no domains entry. Players can only reach it through a server switch from within the network.
INFO
Only intercepted modes (client_only, offline) can belong to a network. Forwarding modes (passthrough, zero_copy, server_only) are rejected during config validation if they specify a network.
Server manager
You can pair client-only mode with a server manager that starts and stops the backend automatically:
name = "creative"
domains = ["creative.mc.example.com"]
addresses = ["127.0.0.1:25566"]
proxy_mode = "client_only"
[server_manager]
type = "local"
command = "java"
working_dir = "/opt/minecraft/creative"
args = ["-Xmx4G", "-jar", "server.jar", "nogui"]
ready_pattern = 'For help, type "help"'
shutdown_timeout = "30s"
shutdown_after = "15m"2
3
4
5
6
7
8
9
10
11
12
13
When a player connects and the backend is down, the proxy starts the server process and holds the player in a limbo state until the backend is ready.
Constraints
Client-only is an intercepted mode. These rules apply:
- The backend must run with
online-mode=false. The proxy already authenticated the player. - Works with Minecraft versions that Infrarust can parse (currently 1.7.2 through 26.3). Future protocol versions require an Infrarust update.
- Domains are optional if the server belongs to a network and is only reachable via server switching.
- Higher resource usage than forwarding modes because the proxy parses every packet instead of copying raw bytes.
Compared to other modes
If client-only doesn't fit your needs:
- Don't need server switching or plugins? Use passthrough. Lower overhead, works with every Minecraft version.
- Need lower CPU on Linux without server switching? Use zero-copy.
- Need server switching without Mojang authentication? Use offline. Same capabilities, but any username can connect.