diff --git a/paper-api/src/main/java/com/destroystokyo/paper/event/player/PlayerHandshakeEvent.java b/paper-api/src/main/java/com/destroystokyo/paper/event/player/PlayerHandshakeEvent.java
index 31c77a4b64f9..14c396e60216 100644
--- a/paper-api/src/main/java/com/destroystokyo/paper/event/player/PlayerHandshakeEvent.java
+++ b/paper-api/src/main/java/com/destroystokyo/paper/event/player/PlayerHandshakeEvent.java
@@ -25,6 +25,7 @@ public class PlayerHandshakeEvent extends Event implements Cancellable {
private static final HandlerList HANDLER_LIST = new HandlerList();
+ private final UUID connectionId;
private final String originalHandshake;
private final String originalSocketAddressHostname;
private @Nullable String serverHostname;
@@ -42,9 +43,16 @@ public PlayerHandshakeEvent(final String originalHandshake, final boolean cancel
this(originalHandshake, "127.0.0.1", cancelled);
}
+ @Deprecated
@ApiStatus.Internal
public PlayerHandshakeEvent(final String originalHandshake, final String originalSocketAddressHostname, final boolean cancelled) {
+ this(UUID.randomUUID(), originalHandshake, originalSocketAddressHostname, cancelled);
+ }
+
+ @ApiStatus.Internal
+ public PlayerHandshakeEvent(final UUID connectionId, final String originalHandshake, final String originalSocketAddressHostname, final boolean cancelled) {
super(true);
+ this.connectionId = connectionId;
this.originalHandshake = originalHandshake;
this.originalSocketAddressHostname = originalSocketAddressHostname;
this.cancelled = cancelled;
@@ -59,6 +67,19 @@ public String getOriginalHandshake() {
return this.originalHandshake;
}
+ /**
+ * Gets the stable connection identifier associated with this handshake.
+ *
+ *
This identifier remains the same throughout the entire connection
+ * lifecycle and can be used to correlate this handshake with subsequent
+ * connection stages such as login and join.
+ *
+ * @return the stable connection identifier
+ */
+ public UUID getConnectionId() {
+ return this.connectionId;
+ }
+
/**
* Gets the original socket address hostname.
*
diff --git a/paper-api/src/main/java/com/destroystokyo/paper/event/server/PaperServerListPingEvent.java b/paper-api/src/main/java/com/destroystokyo/paper/event/server/PaperServerListPingEvent.java
index 15b12abe17da..45c98c7353d8 100644
--- a/paper-api/src/main/java/com/destroystokyo/paper/event/server/PaperServerListPingEvent.java
+++ b/paper-api/src/main/java/com/destroystokyo/paper/event/server/PaperServerListPingEvent.java
@@ -56,9 +56,16 @@ public class PaperServerListPingEvent extends ServerListPingEvent implements Can
private Object[] players;
@ApiStatus.Internal
+ @Deprecated(forRemoval = true)
public PaperServerListPingEvent(@NotNull StatusClient client, @NotNull net.kyori.adventure.text.Component motd, int numPlayers, int maxPlayers,
@NotNull String version, int protocolVersion, @Nullable CachedServerIcon favicon) {
- super("", client.getAddress().getAddress(), motd, numPlayers, maxPlayers);
+ this(UUID.randomUUID(), client, motd, numPlayers, maxPlayers, version, protocolVersion, favicon);
+ }
+
+ @ApiStatus.Internal
+ public PaperServerListPingEvent(@NotNull UUID connectionId, @NotNull StatusClient client, @NotNull net.kyori.adventure.text.Component motd, int numPlayers, int maxPlayers,
+ @NotNull String version, int protocolVersion, @Nullable CachedServerIcon favicon) {
+ super(connectionId, "", client.getAddress().getAddress(), motd, numPlayers, maxPlayers);
this.client = client;
this.numPlayers = numPlayers;
this.version = version;
diff --git a/paper-api/src/main/java/io/papermc/paper/connection/PlayerConnection.java b/paper-api/src/main/java/io/papermc/paper/connection/PlayerConnection.java
index 2dd2fdc199a7..317d205b2ed2 100644
--- a/paper-api/src/main/java/io/papermc/paper/connection/PlayerConnection.java
+++ b/paper-api/src/main/java/io/papermc/paper/connection/PlayerConnection.java
@@ -2,6 +2,7 @@
import java.net.InetSocketAddress;
import java.net.SocketAddress;
+import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.jspecify.annotations.Nullable;
@@ -62,4 +63,19 @@ public interface PlayerConnection {
* @return the player's proxy address, null if the server doesn't have Proxy Protocol enabled, or the player didn't connect to an HAProxy instance
*/
@Nullable InetSocketAddress getHAProxyAddress();
+
+ /**
+ * Gets a stable identifier for this connection that remains the same
+ * throughout its entire lifecycle, from the initial handshake until the
+ * connection is closed or the player fully joins the server.
+ *
+ * This identifier is unique per connection and can be used to reliably
+ * correlate data collected during earlier connection stages (e.g. handshake
+ * or login) with the player that eventually joins the server, without
+ * relying on the client's IP address.
+ *
+ * @return the stable connection identifier
+ */
+ UUID getConnectionId();
+
}
diff --git a/paper-api/src/main/java/org/bukkit/event/server/ServerListPingEvent.java b/paper-api/src/main/java/org/bukkit/event/server/ServerListPingEvent.java
index fdc39724354f..703571648283 100644
--- a/paper-api/src/main/java/org/bukkit/event/server/ServerListPingEvent.java
+++ b/paper-api/src/main/java/org/bukkit/event/server/ServerListPingEvent.java
@@ -2,6 +2,7 @@
import com.google.common.base.Preconditions;
import java.net.InetAddress;
+import java.util.UUID;
import java.util.Iterator;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
@@ -32,28 +33,19 @@ public class ServerListPingEvent extends ServerEvent implements Iterable
private final int numPlayers;
private Component motd;
private int maxPlayers;
+ private final UUID connectionId;
@ApiStatus.Internal
@Deprecated(forRemoval = true)
public ServerListPingEvent(@NotNull final String hostname, @NotNull final InetAddress address, @NotNull final String motd, final int numPlayers, final int maxPlayers) {
- super(true);
+ this(UUID.randomUUID(), hostname, address, LegacyComponentSerializer.legacySection().deserialize(motd), numPlayers, maxPlayers);
Preconditions.checkArgument(numPlayers >= 0, "Cannot have negative number of players online", numPlayers);
- this.hostname = hostname;
- this.address = address;
- this.motd = LegacyComponentSerializer.legacySection().deserialize(motd);
- this.numPlayers = numPlayers;
- this.maxPlayers = maxPlayers;
}
@ApiStatus.Internal
@Deprecated(forRemoval = true)
protected ServerListPingEvent(@NotNull final String hostname, @NotNull final InetAddress address, @NotNull final String motd, final int maxPlayers) {
- super(true);
- this.numPlayers = MAGIC_PLAYER_COUNT;
- this.hostname = hostname;
- this.address = address;
- this.motd = LegacyComponentSerializer.legacySection().deserialize(motd);
- this.maxPlayers = maxPlayers;
+ this(UUID.randomUUID(), hostname, address, LegacyComponentSerializer.legacySection().deserialize(motd), maxPlayers);
}
@ApiStatus.Internal
@@ -63,8 +55,15 @@ public ServerListPingEvent(@NotNull final InetAddress address, @NotNull final Co
}
@ApiStatus.Internal
+ @Deprecated(forRemoval = true)
public ServerListPingEvent(@NotNull final String hostname, @NotNull final InetAddress address, @NotNull final Component motd, final int numPlayers, final int maxPlayers) {
+ this(UUID.randomUUID(), hostname, address, motd, numPlayers, maxPlayers);
+ }
+
+ @ApiStatus.Internal
+ public ServerListPingEvent(@NotNull final UUID connectionId, @NotNull final String hostname, @NotNull final InetAddress address, @NotNull final Component motd, final int numPlayers, final int maxPlayers) {
super(true);
+ this.connectionId = connectionId;
this.hostname = hostname;
this.address = address;
this.motd = motd;
@@ -84,8 +83,15 @@ protected ServerListPingEvent(@NotNull final InetAddress address, @NotNull final
* count.
*/
@ApiStatus.Internal
+ @Deprecated(forRemoval = true)
protected ServerListPingEvent(final @NotNull String hostname, final @NotNull InetAddress address, final @NotNull Component motd, final int maxPlayers) {
+ this(UUID.randomUUID(), hostname, address, motd, maxPlayers);
+ }
+
+ @ApiStatus.Internal
+ protected ServerListPingEvent(final @NotNull UUID connectionId, final @NotNull String hostname, final @NotNull InetAddress address, final @NotNull Component motd, final int maxPlayers) {
this.numPlayers = MAGIC_PLAYER_COUNT;
+ this.connectionId = connectionId;
this.hostname = hostname;
this.address = address;
this.motd = motd;
@@ -103,6 +109,20 @@ public String getHostname() {
return this.hostname;
}
+ /**
+ * Gets the stable connection identifier associated with this ping.
+ *
+ * This identifier remains the same throughout the entire connection
+ * lifecycle and can be used to correlate this ping with subsequent
+ * connection stages such as handshake, login and join.
+ *
+ * @return the stable connection identifier
+ */
+ @NotNull
+ public UUID getConnectionId() {
+ return this.connectionId;
+ }
+
/**
* Get the address the ping is coming from.
*
diff --git a/paper-server/patches/features/0035-Add-stable-connection-identifier-API.patch b/paper-server/patches/features/0035-Add-stable-connection-identifier-API.patch
new file mode 100644
index 000000000000..ad06b8276418
--- /dev/null
+++ b/paper-server/patches/features/0035-Add-stable-connection-identifier-API.patch
@@ -0,0 +1,37 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: Umbre11as
+Date: Sun, 30 Aug 2026 05:00:22 +0300
+Subject: [PATCH] Add stable connection identifier API
+
+
+diff --git a/net/minecraft/network/Connection.java b/net/minecraft/network/Connection.java
+index 9fbc7e93712672dbab67f931e2aee447d803607b..e952765eb0e9a5a41b6b5948a9b3b85f4d4b2b49 100644
+--- a/net/minecraft/network/Connection.java
++++ b/net/minecraft/network/Connection.java
+@@ -93,6 +93,13 @@ public class Connection extends SimpleChannelInboundHandler> {
+ public java.net.InetSocketAddress virtualHost;
+ private static boolean enableExplicitFlush = Boolean.getBoolean("paper.explicit-flush"); // Paper - Disable explicit connection flushing
+ // Paper end
++ // Paper start - stable connection identifier
++ private final UUID connectionId = UUID.randomUUID();
++
++ public UUID getConnectionId() {
++ return this.connectionId;
++ }
++ // Paper end - stable connection identifier
+ // Paper start - add utility methods
+ public final net.minecraft.server.level.ServerPlayer getPlayer() {
+ if (this.packetListener instanceof net.minecraft.server.network.ServerGamePacketListenerImpl impl) {
+diff --git a/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java b/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java
+index 649f9646abb1bd84f1f22409aee09d2a4d27e581..4826053a4bd2c2449d70d64170262cfbc2125b16 100644
+--- a/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java
++++ b/net/minecraft/server/network/ServerHandshakePacketListenerImpl.java
+@@ -122,7 +122,7 @@ public class ServerHandshakePacketListenerImpl implements ServerHandshakePacketL
+ if (com.destroystokyo.paper.event.player.PlayerHandshakeEvent.getHandlerList().getRegisteredListeners().length != 0) { // Hello? Can you hear me?
+ java.net.SocketAddress socketAddress = this.connection.getRemoteAddress();
+ String hostnameOfRemote = socketAddress instanceof java.net.InetSocketAddress ? ((java.net.InetSocketAddress) socketAddress).getHostString() : java.net.InetAddress.getLoopbackAddress().getHostAddress();
+- com.destroystokyo.paper.event.player.PlayerHandshakeEvent event = new com.destroystokyo.paper.event.player.PlayerHandshakeEvent(packet.hostName(), hostnameOfRemote, !proxyLogicEnabled);
++ com.destroystokyo.paper.event.player.PlayerHandshakeEvent event = new com.destroystokyo.paper.event.player.PlayerHandshakeEvent(this.connection.getConnectionId(), packet.hostName(), hostnameOfRemote, !proxyLogicEnabled);
+ if (event.callEvent()) {
+ // If we've failed somehow, let the client know so and go no further.
+ if (event.isFailed()) {
diff --git a/paper-server/src/main/java/com/destroystokyo/paper/network/PaperLegacyStatusClient.java b/paper-server/src/main/java/com/destroystokyo/paper/network/PaperLegacyStatusClient.java
index ebc03a8e4780..62b015892f63 100644
--- a/paper-server/src/main/java/com/destroystokyo/paper/network/PaperLegacyStatusClient.java
+++ b/paper-server/src/main/java/com/destroystokyo/paper/network/PaperLegacyStatusClient.java
@@ -7,6 +7,7 @@
import org.apache.commons.lang3.StringUtils;
import java.net.InetSocketAddress;
+import java.util.UUID;
import javax.annotation.Nullable;
@@ -46,7 +47,7 @@ public boolean isLegacy() {
public static PaperServerListPingEvent processRequest(MinecraftServer server,
InetSocketAddress address, int protocolVersion, @Nullable InetSocketAddress virtualHost) {
- PaperServerListPingEvent event = new PaperServerListPingEventImpl(server,
+ PaperServerListPingEvent event = new PaperServerListPingEventImpl(UUID.randomUUID(), server,
new PaperLegacyStatusClient(address, protocolVersion, virtualHost), Byte.MAX_VALUE, null);
server.server.getPluginManager().callEvent(event);
diff --git a/paper-server/src/main/java/com/destroystokyo/paper/network/PaperServerListPingEventImpl.java b/paper-server/src/main/java/com/destroystokyo/paper/network/PaperServerListPingEventImpl.java
index 13b03dab807c..6ea7abb92d1f 100644
--- a/paper-server/src/main/java/com/destroystokyo/paper/network/PaperServerListPingEventImpl.java
+++ b/paper-server/src/main/java/com/destroystokyo/paper/network/PaperServerListPingEventImpl.java
@@ -6,14 +6,15 @@
import org.bukkit.entity.Player;
import org.bukkit.util.CachedServerIcon;
+import java.util.UUID;
import javax.annotation.Nullable;
class PaperServerListPingEventImpl extends PaperServerListPingEvent {
private final MinecraftServer server;
- PaperServerListPingEventImpl(MinecraftServer server, StatusClient client, int protocolVersion, @Nullable CachedServerIcon icon) {
- super(client, server.motd(), server.getPlayerCount(), server.getMaxPlayers(),
+ PaperServerListPingEventImpl(UUID connectionId, MinecraftServer server, StatusClient client, int protocolVersion, @Nullable CachedServerIcon icon) {
+ super(connectionId, client, server.motd(), server.getPlayerCount(), server.getMaxPlayers(),
server.getServerModName() + ' ' + server.getServerVersion(), protocolVersion, icon);
this.server = server;
}
diff --git a/paper-server/src/main/java/com/destroystokyo/paper/network/StandardPaperServerListPingEventImpl.java b/paper-server/src/main/java/com/destroystokyo/paper/network/StandardPaperServerListPingEventImpl.java
index 5e89bad7231d..624840c2e447 100644
--- a/paper-server/src/main/java/com/destroystokyo/paper/network/StandardPaperServerListPingEventImpl.java
+++ b/paper-server/src/main/java/com/destroystokyo/paper/network/StandardPaperServerListPingEventImpl.java
@@ -21,7 +21,7 @@ public final class StandardPaperServerListPingEventImpl extends PaperServerListP
private List originalSample;
private StandardPaperServerListPingEventImpl(MinecraftServer server, Connection connection, ServerStatus ping) {
- super(server, new PaperStatusClient(connection), ping.version().map(ServerStatus.Version::protocol).orElse(-1), server.server.getServerIcon());
+ super(connection.getConnectionId(), server, new PaperStatusClient(connection), ping.version().map(ServerStatus.Version::protocol).orElse(-1), server.server.getServerIcon());
this.originalSample = ping.players().map(ServerStatus.Players::sample).orElse(null); // GH-1473 - pre-tick race condition NPE
}
diff --git a/paper-server/src/main/java/io/papermc/paper/connection/PaperCommonConnection.java b/paper-server/src/main/java/io/papermc/paper/connection/PaperCommonConnection.java
index 5cc97ae87c1d..ae710f2c760e 100644
--- a/paper-server/src/main/java/io/papermc/paper/connection/PaperCommonConnection.java
+++ b/paper-server/src/main/java/io/papermc/paper/connection/PaperCommonConnection.java
@@ -7,6 +7,7 @@
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.Map;
+import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.network.protocol.common.ClientboundCustomReportDetailsPacket;
import net.minecraft.network.protocol.common.ClientboundServerLinksPacket;
@@ -105,6 +106,11 @@ public InetSocketAddress getClientAddress() {
return this.packetListener.connection.haProxyAddress instanceof final InetSocketAddress inetSocketAddress ? inetSocketAddress : null;
}
+ @Override
+ public UUID getConnectionId() {
+ return this.packetListener.connection.getConnectionId();
+ }
+
@Override
public void storeCookie(final NamespacedKey key, final byte[] value) {
Preconditions.checkArgument(key != null, "Cookie key cannot be null");
diff --git a/paper-server/src/main/java/io/papermc/paper/connection/PaperPlayerLoginConnection.java b/paper-server/src/main/java/io/papermc/paper/connection/PaperPlayerLoginConnection.java
index 6fcaa17ff5d9..1424b1fc8613 100644
--- a/paper-server/src/main/java/io/papermc/paper/connection/PaperPlayerLoginConnection.java
+++ b/paper-server/src/main/java/io/papermc/paper/connection/PaperPlayerLoginConnection.java
@@ -5,6 +5,7 @@
import io.papermc.paper.adventure.PaperAdventure;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
+import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
import org.jspecify.annotations.NullMarked;
@@ -66,4 +67,10 @@ public void disconnect(final Component component) {
public boolean isConnected() {
return this.packetListener.connection.isConnected();
}
+
+ @Override
+ public UUID getConnectionId() {
+ return this.packetListener.connection.getConnectionId();
+ }
+
}