Repository: btraceio/btrace (2026-07-13T09:09:13Z)

Issue:
[3.0] Secure the prepared-mode BTrace control endpoint
## Context

btrace-agent currently creates new ServerSocket(port), which normally binds wildcard interfaces. Probe commands are accepted without an authentication step. This is not suitable as the foundation for a startup-loaded diagnostic endpoint.

## Scope

Implement an authenticated prepared-mode control path.

Recommended design:
- Loopback bind by default.
- Explicit bind address support.
- Port 0 support and btrace.port discovery.
- Authentication before settings, probe, reconnect, list, event, or exit commands.
- Owner-protected token-file lifecycle for local mode.
- Explicitly configured and authenticated remote mode only.
- No secrets in process arguments or logs.
- V1 and V2 protocol coverage.

Likely areas:
- btrace-core Args
- btrace-agent Main and RemoteClient
- btrace-client Client and Main
- V1/V2 command and adapter classes

## Acceptance criteria

- [ ] Prepared mode listens only on loopback by default.
- [ ] Port 0 publishes a discoverable nonzero btrace.port.
- [ ] Missing and incorrect credentials cannot submit commands.
- [ ] Correct credentials permit probe submission and reconnect.
- [ ] Non-loopback binding without authenticated remote configuration fails closed.
- [ ] Authentication secrets never appear in normal or debug logs.
- [ ] Startup-script noServer=true opens no socket.
- [ ] Shutdown closes the socket and removes generated credential material.
- [ ] Tests cover V1, V2, malformed credentials, and IPv4/IPv6 environments.

If this cannot be completed safely for 3.0.0, make premain server startup explicitly opt-in and remove prepared-mode claims from launch material.


Discussion:

Patch:
diff --git a/AGENTS.md b/AGENTS.md
index f72871cc9..4dfd09107 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,7 @@ For the developer command reference and code-navigation pointers, see [CLAUDE.md
 - In Java code, import types and use simple names; do not introduce fully qualified type names in source.
 - Main code targets Java 8 and uses the Java 11 toolchain. Follow Spotless/Google Java Format.
 - Unit tests live in `src/test/java` and use `*Test`; integration tests live in `integration-tests/src/test/java`.
+- Changes to user-visible behavior that crosses modules or process boundaries must include end-to-end functional coverage in `integration-tests`; unit and component tests are required where useful but are not a substitute for exercising the real client, agent, target JVM, and protocol interaction.
 
 ## Build and verification
 
diff --git a/btrace-agent/src/main/java/io/btrace/agent/ControlServer.java b/btrace-agent/src/main/java/io/btrace/agent/ControlServer.java
new file mode 100644
index 000000000..0c1d5c8b6
--- /dev/null
+++ b/btrace-agent/src/main/java/io/btrace/agent/ControlServer.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import static io.btrace.core.Args.AUTH_TOKEN_FILE;
+import static io.btrace.core.Args.BIND_ADDRESS;
+import static io.btrace.core.Args.PORT;
+
+import io.btrace.core.ArgsMap;
+import java.io.Closeable;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+
+final class ControlServer implements Closeable {
+  static final String PORT_PROPERTY = "btrace.port";
+  static final String ADDRESS_PROPERTY = "btrace.address";
+  static final String AUTH_REQUIRED_PROPERTY = "btrace.auth.required";
+  static final String TOKEN_FILE_PROPERTY = "btrace.auth.tokenFile";
+
+  private final ServerSocket socket;
+  private final PreparedModeCredentials credentials;
+
+  private ControlServer(ServerSocket socket, PreparedModeCredentials credentials) {
+    this.socket = socket;
+    this.credentials = credentials;
+  }
+
+  static ControlServer open(ArgsMap args, boolean preparedMode) throws IOException {
+    int port = parsePort(args.get(PORT));
+    InetAddress address = resolveAddress(args.get(BIND_ADDRESS));
+    PreparedModeCredentials credentials = null;
+    ServerSocket socket = null;
+    try {
+      if (preparedMode) {
+        credentials = PreparedModeCredentials.create(args.get(AUTH_TOKEN_FILE));
+      }
+
+      socket = new ServerSocket();
+      socket.bind(new InetSocketAddress(address, port));
+      if (socket.getLocalPort() <= 0) {
+        throw new IOException("BTrace control server did not obtain a valid port");
+      }
+
+      ControlServer server = new ControlServer(socket, credentials);
+      server.publishDiscovery();
+      return server;
+    } catch (IOException | RuntimeException failure) {
+      if (socket != null) {
+        try {
+          socket.close();
+        } catch (IOException ignored) {
+        }
+      }
+      if (credentials != null) {
+        credentials.close();
+      }
+      throw failure;
+    }
+  }
+
+  Socket accept() throws IOException {
+    return socket.accept();
+  }
+
+  boolean isClosed() {
+    return socket.isClosed();
+  }
+
+  int getPort() {
+    return socket.getLocalPort();
+  }
+
+  InetAddress getAddress() {
+    return socket.getInetAddress();
+  }
+
+  byte[] copyAuthenticationToken() {
+    return credentials != null ? credentials.copyToken() : null;
+  }
+
+  boolean isAuthenticationRequired() {
+    return credentials != null;
+  }
+
+  @Override
+  public void close() throws IOException {
+    IOException failure = null;
+    try {
+      socket.close();
+    } catch (IOException e) {
+      failure = e;
+    }
+    try {
+      if (credentials != null) {
+        credentials.close();
+      }
+    } catch (IOException e) {
+      if (failure == null) {
+        failure = e;
+      } else {
+        failure.addSuppressed(e);
+      }
+    } finally {
+      clearDiscovery();
+    }
+    if (failure != null) {
+      throw failure;
+    }
+  }
+
+  private void publishDiscovery() {
+    System.setProperty(PORT_PROPERTY, String.valueOf(getPort()));
+    System.setProperty(ADDRESS_PROPERTY, getAddress().getHostAddress());
+    System.setProperty(AUTH_REQUIRED_PROPERTY, String.valueOf(credentials != null));
+    if (credentials != null) {
+      System.setProperty(TOKEN_FILE_PROPERTY, credentials.getPath().toString());
+    } else {
+      System.clearProperty(TOKEN_FILE_PROPERTY);
+    }
+  }
+
+  private void clearDiscovery() {
+    clearIfEqual(PORT_PROPERTY, String.valueOf(getPort()));
+    clearIfEqual(ADDRESS_PROPERTY, getAddress().getHostAddress());
+    clearIfEqual(AUTH_REQUIRED_PROPERTY, String.valueOf(credentials != null));
+    if (credentials != null) {
+      clearIfEqual(TOKEN_FILE_PROPERTY, credentials.getPath().toString());
+    }
+  }
+
+  private static void clearIfEqual(String property, String expected) {
+    if (expected.equals(System.getProperty(property))) {
+      System.clearProperty(property);
+    }
+  }
+
+  private static int parsePort(String configuredPort) throws IOException {
+    if (configuredPort == null || configuredPort.trim().isEmpty()) {
+      return Main.BTRACE_DEFAULT_PORT;
+    }
+    try {
+      int port = Integer.parseInt(configuredPort.trim());
+      if (port < 0 || port > 65535) {
+        throw new IOException("BTrace control server port is out of range");
+      }
+      return port;
+    } catch (NumberFormatException e) {
+      throw new IOException("BTrace control server port is invalid");
+    }
+  }
+
+  private static InetAddress resolveAddress(String configuredAddress) throws IOException {
+    InetAddress address =
+        configuredAddress == null || configuredAddress.trim().isEmpty()
+            ? InetAddress.getLoopbackAddress()
+            : InetAddress.getByName(configuredAddress.trim());
+    if (!address.isLoopbackAddress()) {
+      throw new IOException("BTrace 3.0 control server supports loopback binding only");
+    }
+    return address;
+  }
+}
diff --git a/btrace-agent/src/main/java/io/btrace/agent/Main.java b/btrace-agent/src/main/java/io/btrace/agent/Main.java
index d79747016..3dd4784a4 100644
--- a/btrace-agent/src/main/java/io/btrace/agent/Main.java
+++ b/btrace-agent/src/main/java/io/btrace/agent/Main.java
@@ -34,7 +34,6 @@
 import static io.btrace.core.Args.LIBS;
 import static io.btrace.core.Args.NO_SERVER;
 import static io.btrace.core.Args.OUTPUT;
-import static io.btrace.core.Args.PORT;
 import static io.btrace.core.Args.PROBES;
 import static io.btrace.core.Args.PROBE_DESC_PATH;
 import static io.btrace.core.Args.SCRIPT;
@@ -77,7 +76,6 @@
 import java.lang.instrument.UnmodifiableClassException;
 import java.lang.invoke.MethodHandles;
 import java.lang.invoke.MethodType;
-import java.net.ServerSocket;
 import java.net.Socket;
 import java.net.URL;
 import java.nio.file.FileVisitResult;
@@ -87,6 +85,7 @@
 import java.nio.file.Paths;
 import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -141,7 +140,7 @@ public Thread newThread(Runnable r) {
   private static volatile Long fileRollMilliseconds;
   private static volatile ExtensionLoader extensionLoader;
   private static volatile boolean serverRunning = true;
-  private static ServerSocket serverSocket;
+  private static ControlServer controlServer;
   // Track appended jars to avoid duplicate classpath entries
   private static final Set<Path> BOOT_ADDED = Collections.synchronizedSet(new LinkedHashSet<>());
   private static final Set<Path> SYSTEM_ADDED = Collections.synchronizedSet(new LinkedHashSet<>());
@@ -206,16 +205,19 @@ private static synchronized void main(String args, Instrumentation inst) {
       // settings are all built-up; set the logging system properties accordingly
       DebugSupport.initLoggers(settings.isDebug(), log);
 
-      String tmp = argMap.get(NO_SERVER);
-      // noServer is defaulting to true if startup scripts are defined
-      boolean noServer = tmp != null ? Boolean.parseBoolean(tmp) : hasScripts();
       Thread agentThread = null;
-      if (noServer) {
+      if (!shouldStartServer(argMap)) {
         log.debug("noServer is true, server not started");
       } else {
-        // Deliberately an anonymous class, not a lambda: this runs during -javaagent premain(),
-        // before the JVM's own java.lang.invoke bootstrap is guaranteed complete. See
-        // io.btrace.instr.BootstrapPathIndyFreedomTest and the investigation doc it references.
+        System.setProperty("btrace.wireio", String.valueOf(WireIO.VERSION));
+        String scriptOutputFile = settings.getOutputFile();
+        if (scriptOutputFile != null && !scriptOutputFile.isEmpty()) {
+          System.setProperty("btrace.output", scriptOutputFile);
+        }
+        controlServer = ControlServer.open(argMap, "premain".equals(agentMode));
+        serverRunning = true;
+        installServerShutdownHook();
+        ControlServer endpoint = controlServer;
         agentThread =
             new Thread(
                 new Runnable() {
@@ -223,7 +225,7 @@ private static synchronized void main(String args, Instrumentation inst) {
                   public void run() {
                     BTraceRuntime.enter();
                     try {
-                      startServer();
+                      runServer(endpoint);
                     } finally {
                       BTraceRuntime.leave();
                     }
@@ -247,18 +249,6 @@ public void run() {
       if (AGENT_DEBUG) System.err.println("[BTrace Agent] Initializing unsafe");
       BTraceRuntime.initUnsafe();
       if (AGENT_DEBUG) System.err.println("[BTrace Agent] Unsafe initialized");
-      if (agentThread != null) {
-        BTraceRuntime.enter();
-        try {
-          agentThread.setDaemon(true);
-          log.debug("starting agent thread");
-
-          agentThread.start();
-        } finally {
-          BTraceRuntime.leave();
-        }
-      }
-
       // Force io.btrace.instr.ClassFilter's static initializer to run to completion now, on
       // this single thread, before the transformer below can possibly be reached by any other
       // thread. ClassFilter.isSensitiveClass(...) is the first thing BTraceTransformer.transform()
@@ -314,10 +304,21 @@ public void run() {
       // initialize extension system after transformer is installed so early app code is not delayed
       if (AGENT_DEBUG) System.err.println("[BTrace Agent] Initializing extensions");
       initExtensions();
+      if (agentThread != null) {
+        BTraceRuntime.enter();
+        try {
+          agentThread.setDaemon(true);
+          log.debug("starting agent thread");
+          agentThread.start();
+        } finally {
+          BTraceRuntime.leave();
+        }
+      }
       if (AGENT_DEBUG)
         System.err.println(
             "[BTrace Agent] Initialization complete, " + startedScripts + " scripts started");
     } catch (Throwable t) {
+      shutdownServer();
       // FATAL errors should always be printed
       System.err.println(
           "[BTrace Agent] FATAL: Initialization failed: "
@@ -332,8 +333,11 @@ public void run() {
     }
   }
 
-  private static boolean hasScripts() {
-    return argMap.containsKey(SCRIPT) || argMap.containsKey(SCRIPT_DIR);
+  static boolean shouldStartServer(ArgsMap args) {
+    String configured = args.get(NO_SERVER);
+    return configured != null
+        ? !Boolean.parseBoolean(configured)
+        : !(args.containsKey(SCRIPT) || args.containsKey(SCRIPT_DIR));
   }
 
   private static final class LogValue {
@@ -1384,81 +1388,79 @@ private static boolean loadBTraceScript(String filePath, boolean traceToStdOut)
 
   // -- Internals only below this point
   @SuppressWarnings("InfiniteLoopStatement")
-  private static void startServer() {
-    int port = BTRACE_DEFAULT_PORT;
-    String p = argMap.get(PORT);
-    if (p != null) {
-      try {
-        port = Integer.parseInt(p);
-      } catch (NumberFormatException exp) {
-        error("invalid port assuming default..");
-      }
-    }
-    try {
-      if (log.isDebugEnabled()) {
-        log.debug("starting server at port {}", port);
-      }
-      System.setProperty("btrace.wireio", String.valueOf(WireIO.VERSION));
-
-      String scriptOutputFile = settings.getOutputFile();
-      if (scriptOutputFile != null && !scriptOutputFile.isEmpty()) {
-        System.setProperty("btrace.output", scriptOutputFile);
-      }
-      serverSocket = new ServerSocket(port);
-      System.setProperty("btrace.port", String.valueOf(serverSocket.getLocalPort()));
-
-      // Add shutdown hook to close server socket on JVM exit
-      // Deliberately an anonymous class, not a lambda: this method can run reachable from
-      // -javaagent premain(), before the JVM's own java.lang.invoke bootstrap is guaranteed
-      // complete. See io.btrace.instr.BootstrapPathIndyFreedomTest and the investigation doc it
-      // references.
-      Runtime.getRuntime()
-          .addShutdownHook(
-              new Thread(
-                  new Runnable() {
-                    @Override
-                    public void run() {
-                      serverRunning = false;
-                      if (serverSocket != null && !serverSocket.isClosed()) {
-                        try {
-                          serverSocket.close();
-                          log.debug("BTrace server socket closed");
-                        } catch (IOException e) {
-                          log.debug("Error closing server socket", e);
-                        }
-                      }
-                    }
-                  },
-                  "BTrace Server Shutdown"));
-
-    } catch (IOException ioexp) {
-      log.error("Failed to start BTrace server on port {}", port, ioexp);
-      return;
+  private static void runServer(ControlServer endpoint) {
+    if (log.isDebugEnabled()) {
+      log.debug(
+          "starting server at {}:{} with authentication {}",
+          endpoint.getAddress().getHostAddress(),
+          endpoint.getPort(),
+          endpoint.isAuthenticationRequired() ? "required" : "disabled");
     }
-
     while (serverRunning) {
+      Socket sock = null;
+      boolean handedOff = false;
+      byte[] authenticationToken = null;
       try {
         log.debug("waiting for clients");
-        Socket sock = serverSocket.accept();
+        sock = endpoint.accept();
         if (log.isDebugEnabled()) {
-          log.debug("client accepted {}", sock);
+          log.debug("client accepted from {}", sock.getRemoteSocketAddress());
         }
+        authenticationToken = endpoint.copyAuthenticationToken();
         ClientContext ctx = new ClientContext(inst, transformer, argMap, settings);
-        // Deliberately an anonymous class, not a method reference: see the shutdown-hook comment
-        // above.
-        RemoteClient.getClient(
-            ctx,
-            sock,
-            new Function<Client, Future<?>>() {
-              @Override
-              public Future<?> apply(Client c) {
-                return handleNewClient(c);
-              }
-            });
+        Client client =
+            RemoteClient.getClient(
+                ctx,
+                sock,
+                authenticationToken,
+                new Function<Client, Future<?>>() {
+                  @Override
+                  public Future<?> apply(Client c) {
+                    return handleNewClient(c);
+                  }
+                });
+        handedOff = client != null;
       } catch (RuntimeException | IOException re) {
         if (serverRunning) {
-          log.warn("BTrace server accept failed", re);
+          log.warn("BTrace server connection rejected: {}", re.getMessage());
+        }
+      } finally {
+        if (authenticationToken != null) {
+          Arrays.fill(authenticationToken, (byte) 0);
         }
+        if (!handedOff && sock != null) {
+          try {
+            sock.close();
+          } catch (IOException ignored) {
+          }
+        }
+      }
+    }
+  }
+
+  private static void installServerShutdownHook() {
+    Runtime.getRuntime()
+        .addShutdownHook(
+            new Thread(
+                new Runnable() {
+                  @Override
+                  public void run() {
+                    shutdownServer();
+                  }
+                },
+                "BTrace Server Shutdown"));
+  }
+
+  private static synchronized void shutdownServer() {
+    serverRunning = false;
+    ControlServer endpoint = controlServer;
+    controlServer = null;
+    if (endpoint != null && !endpoint.isClosed()) {
+      try {
+        endpoint.close();
+        log.debug("BTrace server closed");
+      } catch (IOException e) {
+        log.debug("Error closing BTrace server", e);
       }
     }
   }
diff --git a/btrace-agent/src/main/java/io/btrace/agent/PreparedModeCredentials.java b/btrace-agent/src/main/java/io/btrace/agent/PreparedModeCredentials.java
new file mode 100644
index 000000000..befd528ca
--- /dev/null
+++ b/btrace-agent/src/main/java/io/btrace/agent/PreparedModeCredentials.java
@@ -0,0 +1,257 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import io.btrace.core.comm.ConnectionAuthenticator;
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.file.FileStore;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.AclEntry;
+import java.nio.file.attribute.AclEntryPermission;
+import java.nio.file.attribute.AclEntryType;
+import java.nio.file.attribute.AclFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.nio.file.attribute.UserPrincipal;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+final class PreparedModeCredentials implements Closeable {
+  private static final Set<PosixFilePermission> OWNER_READ_WRITE =
+      Collections.unmodifiableSet(
+          EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
+
+  private final Path path;
+  private final boolean generated;
+  private final byte[] token;
+
+  private PreparedModeCredentials(Path path, boolean generated, byte[] token) {
+    this.path = path;
+    this.generated = generated;
+    this.token = token;
+  }
+
+  static PreparedModeCredentials create(String configuredPath) throws IOException {
+    return create(configuredPath, currentFileOwner());
+  }
+
+  static PreparedModeCredentials create(String configuredPath, UserPrincipal expectedOwner)
+      throws IOException {
+    Path path = null;
+    boolean generated = false;
+    byte[] token = null;
+    try {
+      if (configuredPath == null || configuredPath.trim().isEmpty()) {
+        Path directory = Paths.get(System.getProperty("java.io.tmpdir")).toAbsolutePath();
+        path = createSecureTempFile(directory, expectedOwner);
+        generated = true;
+      } else {
+        path = Paths.get(configuredPath).toAbsolutePath().normalize();
+        if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) {
+          if (Files.isSymbolicLink(path) || !Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
+            throw new IOException("Prepared-mode token path must be a regular file");
+          }
+          verifyOwnerOnly(path, expectedOwner);
+        } else {
+          Path parent = path.getParent();
+          if (parent == null || !Files.isDirectory(parent)) {
+            throw new IOException("Prepared-mode token directory does not exist");
+          }
+          createSecureFile(path, expectedOwner);
+          generated = true;
+        }
+      }
+
+      if (generated) {
+        token = generateToken();
+        Files.write(path, token, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
+        verifyOwnerOnly(path, expectedOwner);
+      } else {
+        token = ConnectionAuthenticator.readToken(path);
+      }
+      return new PreparedModeCredentials(path, generated, token);
+    } catch (IOException | RuntimeException failure) {
+      if (token != null) {
+        Arrays.fill(token, (byte) 0);
+      }
+      if (generated && path != null) {
+        Files.deleteIfExists(path);
+      }
+      throw failure;
+    }
+  }
+
+  Path getPath() {
+    return path;
+  }
+
+  byte[] copyToken() {
+    return token.clone();
+  }
+
+  @Override
+  public void close() throws IOException {
+    Arrays.fill(token, (byte) 0);
+    if (generated) {
+      Files.deleteIfExists(path);
+    }
+  }
+
+  private static byte[] generateToken() {
+    byte[] random = new byte[32];
+    new SecureRandom().nextBytes(random);
+    try {
+      return Base64.getUrlEncoder().withoutPadding().encode(random);
+    } finally {
+      Arrays.fill(random, (byte) 0);
+    }
+  }
+
+  private static Path createSecureTempFile(Path directory, UserPrincipal expectedOwner)
+      throws IOException {
+    if (supportsPosix(directory)) {
+      return Files.createTempFile(
+          directory, "btrace-", ".token", PosixFilePermissions.asFileAttribute(OWNER_READ_WRITE));
+    }
+    Path path = Files.createTempFile(directory, "btrace-", ".token");
+    try {
+      restrictAclToOwner(path);
+      verifyOwnerOnly(path, expectedOwner);
+      return path;
+    } catch (IOException | RuntimeException failure) {
+      Files.deleteIfExists(path);
+      throw failure;
+    }
+  }
+
+  private static void createSecureFile(Path path, UserPrincipal expectedOwner) throws IOException {
+    if (supportsPosix(path.getParent())) {
+      Files.createFile(path, PosixFilePermissions.asFileAttribute(OWNER_READ_WRITE));
+      return;
+    }
+    Files.createFile(path);
+    try {
+      restrictAclToOwner(path);
+      verifyOwnerOnly(path, expectedOwner);
+    } catch (IOException | RuntimeException failure) {
+      Files.deleteIfExists(path);
+      throw failure;
+    }
+  }
+
+  private static boolean supportsPosix(Path path) throws IOException {
+    FileStore store = Files.getFileStore(path);
+    return store.supportsFileAttributeView(PosixFileAttributeView.class);
+  }
+
+  private static UserPrincipal currentFileOwner() throws IOException {
+    // File ownership comes from the OS account running the JVM; unlike user.name, it cannot be
+    // changed with a system property.
+    Path reference = Files.createTempFile("btrace-owner-", ".tmp");
+    try {
+      return Files.getOwner(reference, LinkOption.NOFOLLOW_LINKS);
+    } finally {
+      Files.deleteIfExists(reference);
+    }
+  }
+
+  private static void restrictAclToOwner(Path path) throws IOException {
+    AclFileAttributeView view =
+        Files.getFileAttributeView(path, AclFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
+    if (view == null) {
+      throw new IOException("Owner-only token-file permissions are unsupported");
+    }
+    UserPrincipal owner = view.getOwner();
+    AclEntry ownerEntry =
+        AclEntry.newBuilder()
+            .setType(AclEntryType.ALLOW)
+            .setPrincipal(owner)
+            .setPermissions(
+                AclEntryPermission.READ_DATA,
+                AclEntryPermission.WRITE_DATA,
+                AclEntryPermission.APPEND_DATA,
+                AclEntryPermission.READ_NAMED_ATTRS,
+                AclEntryPermission.WRITE_NAMED_ATTRS,
+                AclEntryPermission.READ_ATTRIBUTES,
+                AclEntryPermission.WRITE_ATTRIBUTES,
+                AclEntryPermission.DELETE,
+                AclEntryPermission.READ_ACL,
+                AclEntryPermission.WRITE_ACL,
+                AclEntryPermission.SYNCHRONIZE)
+            .build();
+    view.setAcl(Collections.singletonList(ownerEntry));
+  }
+
+  private static void verifyOwnerOnly(Path path, UserPrincipal expectedOwner) throws IOException {
+    PosixFileAttributeView posix =
+        Files.getFileAttributeView(path, PosixFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
+    if (posix != null) {
+      PosixFileAttributes attributes = posix.readAttributes();
+      if (!attributes.owner().equals(expectedOwner)) {
+        throw new IOException("Prepared-mode token file is owned by another account");
+      }
+      Set<PosixFilePermission> permissions = attributes.permissions();
+      if (!permissions.contains(PosixFilePermission.OWNER_READ)
+          || permissions.contains(PosixFilePermission.GROUP_READ)
+          || permissions.contains(PosixFilePermission.GROUP_WRITE)
+          || permissions.contains(PosixFilePermission.GROUP_EXECUTE)
+          || permissions.contains(PosixFilePermission.OTHERS_READ)
+          || permissions.contains(PosixFilePermission.OTHERS_WRITE)
+          || permissions.contains(PosixFilePermission.OTHERS_EXECUTE)) {
+        throw new IOException("Prepared-mode token file is not owner-protected");
+      }
+      return;
+    }
+
+    AclFileAttributeView acl =
+        Files.getFileAttributeView(path, AclFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
+    if (acl == null) {
+      throw new IOException("Owner-only token-file permissions cannot be verified");
+    }
+    UserPrincipal owner = acl.getOwner();
+    if (!owner.equals(expectedOwner)) {
+      throw new IOException("Prepared-mode token file is owned by another account");
+    }
+    boolean ownerCanRead = false;
+    List<AclEntry> entries = acl.getAcl();
+    for (AclEntry entry : entries) {
+      if (entry.type() != AclEntryType.ALLOW) {
+        continue;
+      }
+      if (entry.principal().equals(owner)) {
+        ownerCanRead |= entry.permissions().contains(AclEntryPermission.READ_DATA);
+      } else if (!entry.permissions().isEmpty()) {
+        throw new IOException("Prepared-mode token file is not owner-protected");
+      }
+    }
+    if (!ownerCanRead) {
+      throw new IOException("Prepared-mode token file is not owner-readable");
+    }
+  }
+}
diff --git a/btrace-agent/src/main/java/io/btrace/agent/RemoteClient.java b/btrace-agent/src/main/java/io/btrace/agent/RemoteClient.java
index 023d71a7b..188800b1e 100644
--- a/btrace-agent/src/main/java/io/btrace/agent/RemoteClient.java
+++ b/btrace-agent/src/main/java/io/btrace/agent/RemoteClient.java
@@ -19,6 +19,7 @@
 import io.btrace.core.*;
 import io.btrace.core.comm.BinaryWireProtocol;
 import io.btrace.core.comm.Command;
+import io.btrace.core.comm.ConnectionAuthenticator;
 import io.btrace.core.comm.DisconnectCommand;
 import io.btrace.core.comm.EventCommand;
 import io.btrace.core.comm.ExitCommand;
@@ -41,6 +42,7 @@
 import java.io.PushbackInputStream;
 import java.net.Socket;
 import java.net.SocketException;
+import java.util.Arrays;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
@@ -80,9 +82,12 @@ public Boolean apply(Command value) {
 
   private final CircularBuffer<Command> delayedCommands = new CircularBuffer<>(5000);
 
-  static Client getClient(ClientContext ctx, Socket sock, Function<Client, Future<?>> initCallback)
+  static Client getClient(
+      ClientContext ctx,
+      Socket sock,
+      byte[] authenticationToken,
+      Function<Client, Future<?>> initCallback)
       throws IOException {
-    SharedSettings settings = ctx.getSettings();
     ProtocolConfig config = ProtocolConfig.fromSystemProperties();
     InputStream rawInput = sock.getInputStream();
     OutputStream output = sock.getOutputStream();
@@ -98,6 +103,9 @@ static Client getClient(ClientContext ctx, Socket sock, Function<Client, Future<
     boolean timeoutRestored = false;
     try {
       sock.setSoTimeout(ProtocolNegotiator.getNegotiationTimeoutMs());
+      if (authenticationToken != null) {
+        ConnectionAuthenticator.authenticateAgent(rawInput, output, authenticationToken);
+      }
       ProtocolVersion negotiated;
       if (config.isAutoNegotiate()) {
         negotiated = negotiator.negotiateAgent(input, output);
@@ -114,6 +122,7 @@ static Client getClient(ClientContext ctx, Socket sock, Function<Client, Future<
           negotiated == ProtocolVersion.V2
               ? new BinaryWireProtocol(input, output)
               : new JavaSerializationProtocol(input, output);
+      SharedSettings settings = ctx.getSettings();
 
       while (true) {
         Command cmd;
@@ -186,6 +195,9 @@ static Client getClient(ClientContext ctx, Socket sock, Function<Client, Future<
         }
       }
     } finally {
+      if (authenticationToken != null) {
+        Arrays.fill(authenticationToken, (byte) 0);
+      }
       // Restore the original timeout on any exit path that did not hand off a live client
       // (negotiation failure, EXIT, or an unexpected command). The socket is typically closed
       // by the caller on these paths, so this is best-effort.
diff --git a/btrace-client/src/main/java/io/btrace/client/Client.java b/btrace-client/src/main/java/io/btrace/client/Client.java
index 2d451c027..8f9def4e1 100644
--- a/btrace-client/src/main/java/io/btrace/client/Client.java
+++ b/btrace-client/src/main/java/io/btrace/client/Client.java
@@ -29,6 +29,7 @@
 import io.btrace.core.comm.BinaryWireProtocol;
 import io.btrace.core.comm.Command;
 import io.btrace.core.comm.CommandListener;
+import io.btrace.core.comm.ConnectionAuthenticator;
 import io.btrace.core.comm.DisconnectCommand;
 import io.btrace.core.comm.EventCommand;
 import io.btrace.core.comm.ExitCommand;
@@ -53,10 +54,12 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.net.ConnectException;
+import java.net.InetAddress;
 import java.net.Socket;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.net.UnknownHostException;
+import java.nio.file.Paths;
 import java.util.Arrays;
 import java.util.Enumeration;
 import java.util.HashMap;
@@ -119,7 +122,9 @@ public class Client {
   }
 
   // port on which BTrace agent listens
-  private final int port;
+  private volatile int port;
+  private volatile String discoveredAddress;
+  private volatile byte[] authenticationToken;
   // the output file or null
   private final String outputFile;
   // are we running debug mode?
@@ -250,7 +255,8 @@ private boolean isAgentAvailableAfterLoadFailure(VirtualMachine vm) {
       Properties serverVmProps = vm.getSystemProperties();
       String serverPort = serverVmProps.getProperty("btrace.port");
       if (serverPort != null) {
-        return Integer.parseInt(serverPort) == port;
+        configureConnection(serverVmProps);
+        return true;
       }
     } catch (Exception ignore) {
       // fall through to port probe
@@ -333,6 +339,7 @@ private static String scanExtensionsDir(File extensionsDir) {
 
   private WireProtocol createProtocol(Socket socket, String host, boolean allowFallback)
       throws IOException {
+    authenticate(socket);
     ProtocolConfig config = ProtocolConfig.fromSystemProperties();
     ProtocolVersion preferred = config.getVersion();
 
@@ -344,8 +351,9 @@ private WireProtocol createProtocol(Socket socket, String host, boolean allowFal
           throw e;
         }
         closeSocketQuietly(socket);
-        Socket fallback = new Socket(host, port);
+        Socket fallback = new Socket(resolveHost(host), port);
         this.sock = fallback;
+        authenticate(fallback);
         return createV1Protocol(fallback);
       }
     }
@@ -391,6 +399,93 @@ private void closeSocketQuietly(Socket socket) {
     }
   }
 
+  private void authenticate(Socket socket) throws IOException {
+    byte[] configuredToken = authenticationToken;
+    if (configuredToken == null) {
+      return;
+    }
+    byte[] token = configuredToken.clone();
+    int previousTimeout = socket.getSoTimeout();
+    try {
+      socket.setSoTimeout(ProtocolNegotiator.getNegotiationTimeoutMs());
+      ConnectionAuthenticator.authenticateClient(
+          socket.getInputStream(), socket.getOutputStream(), token);
+    } finally {
+      Arrays.fill(token, (byte) 0);
+      try {
+        socket.setSoTimeout(previousTimeout);
+      } catch (IOException ignored) {
+      }
+    }
+  }
+
+  String resolveHost(String requestedHost) {
+    String address = discoveredAddress;
+    return address != null && !address.isEmpty() ? address : requestedHost;
+  }
+
+  void configureConnection(Properties targetProperties) throws IOException {
+    int replacementPort = port;
+    String serverPort = targetProperties.getProperty(ControlServerProperties.PORT);
+    if (serverPort != null) {
+      try {
+        int discoveredPort = Integer.parseInt(serverPort);
+        if (discoveredPort < 1 || discoveredPort > 65535) {
+          throw new IOException("Target BTrace server published an invalid port");
+        }
+        replacementPort = discoveredPort;
+      } catch (NumberFormatException e) {
+        throw new IOException("Target BTrace server published an invalid port");
+      }
+    }
+
+    String address = targetProperties.getProperty(ControlServerProperties.ADDRESS);
+    String replacementAddress = null;
+    if (address != null && !address.trim().isEmpty()) {
+      InetAddress discovered = InetAddress.getByName(address.trim());
+      if (!discovered.isLoopbackAddress()) {
+        throw new IOException("Target BTrace server published a non-loopback address");
+      }
+      replacementAddress = discovered.getHostAddress();
+    }
+
+    String authenticationRequired =
+        targetProperties.getProperty(ControlServerProperties.AUTH_REQUIRED, "false");
+    if (!"true".equalsIgnoreCase(authenticationRequired)
+        && !"false".equalsIgnoreCase(authenticationRequired)) {
+      throw new IOException("Target BTrace server published invalid authentication metadata");
+    }
+
+    byte[] replacementToken = null;
+    if (Boolean.parseBoolean(authenticationRequired)) {
+      String tokenFile = targetProperties.getProperty(ControlServerProperties.TOKEN_FILE);
+      if (tokenFile == null || tokenFile.trim().isEmpty()) {
+        throw new IOException("Prepared BTrace agent did not publish authentication credentials");
+      }
+      replacementToken =
+          ConnectionAuthenticator.readToken(Paths.get(tokenFile).toAbsolutePath().normalize());
+    }
+
+    port = replacementPort;
+    discoveredAddress = replacementAddress;
+    replaceAuthenticationToken(replacementToken);
+  }
+
+  private void replaceAuthenticationToken(byte[] replacement) {
+    byte[] previous = authenticationToken;
+    authenticationToken = replacement;
+    if (previous != null) {
+      Arrays.fill(previous, (byte) 0);
+    }
+  }
+
+  private static final class ControlServerProperties {
+    static final String PORT = "btrace.port";
+    static final String ADDRESS = "btrace.address";
+    static final String AUTH_REQUIRED = "btrace.auth.required";
+    static final String TOKEN_FILE = "btrace.auth.tokenFile";
+  }
+
   /**
    * Compiles given BTrace program using given classpath. Errors and warning are written to given
    * PrintWriter.
@@ -653,19 +748,9 @@ public void attach(String pid, String agentPath, String sysCp, String bootCp) th
       Properties serverVmProps = vm.getSystemProperties();
       warnIfDeprecatedTargetJvm(pid, serverVmProps.getProperty("java.version", ""));
       int serverPort = Integer.parseInt(serverVmProps.getProperty("btrace.port", "-1"));
-      boolean agentAlreadyRunning = false;
-      if (serverPort != -1) {
-        if (serverPort != port) {
-          throw new IOException(
-              "Can not attach to PID "
-                  + pid
-                  + " on port "
-                  + port
-                  + ". There is already a BTrace server active on port "
-                  + serverPort
-                  + "!");
-        }
-        agentAlreadyRunning = true;
+      boolean agentAlreadyRunning = serverPort != -1;
+      if (agentAlreadyRunning) {
+        configureConnection(serverVmProps);
         if (log.isDebugEnabled()) {
           log.debug("agent already running on port {}", port);
         }
@@ -795,6 +880,10 @@ public void attach(String pid, String agentPath, String sysCp, String bootCp) th
       if (log.isDebugEnabled()) {
         log.debug("loaded {}", agentPath);
       }
+      Properties loadedAgentProperties = vm.getSystemProperties();
+      if (loadedAgentProperties.getProperty("btrace.port") != null) {
+        configureConnection(loadedAgentProperties);
+      }
     } catch (RuntimeException | IOException re) {
       System.err.println("[DEBUG] IOException/RuntimeException during attach:");
       re.printStackTrace();
@@ -813,6 +902,29 @@ public void attach(String pid, String agentPath, String sysCp, String bootCp) th
     }
   }
 
+  void discoverPreparedAgent(String pid) throws IOException {
+    VirtualMachine vm = null;
+    try {
+      vm = VirtualMachine.attach(pid);
+      Properties targetProperties = vm.getSystemProperties();
+      if (targetProperties.getProperty("btrace.port") == null) {
+        throw new IOException("No BTrace control endpoint is available for PID " + pid);
+      }
+      configureConnection(targetProperties);
+    } catch (RuntimeException | IOException failure) {
+      throw failure;
+    } catch (Exception failure) {
+      throw new IOException("Failed to discover BTrace agent for PID " + pid, failure);
+    } finally {
+      if (vm != null) {
+        try {
+          vm.detach();
+        } catch (IOException ignored) {
+        }
+      }
+    }
+  }
+
   public void connectAndListProbes(String host, CommandListener listener) throws IOException {
     if (sock != null) {
       throw new IllegalStateException();
@@ -824,7 +936,7 @@ public void connectAndListProbes(String host, CommandListener listener) throws I
       long timeout = System.nanoTime() + TimeUnit.NANOSECONDS.convert(5, TimeUnit.SECONDS);
       while (sock == null && System.nanoTime() <= timeout) {
         try {
-          sock = new Socket(host, port);
+          sock = new Socket(resolveHost(host), port);
         } catch (ConnectException e) {
           log.debug("server not yet available; retrying ...");
           Thread.sleep(20);
@@ -865,7 +977,7 @@ void connectAndListFailedExtensions(String host, CommandListener listener) throw
       long timeout = System.nanoTime() + TimeUnit.NANOSECONDS.convert(5, TimeUnit.SECONDS);
       while (sock == null && System.nanoTime() <= timeout) {
         try {
-          sock = new Socket(host, port);
+          sock = new Socket(resolveHost(host), port);
         } catch (ConnectException e) {
           log.debug("server not yet available; retrying ...");
           Thread.sleep(20);
@@ -907,7 +1019,7 @@ void reconnect(String host, String resumeProbe, CommandListener listener, String
       long timeout = System.nanoTime() + TimeUnit.NANOSECONDS.convert(5, TimeUnit.SECONDS);
       while (sock == null && System.nanoTime() <= timeout) {
         try {
-          sock = new Socket(host, port);
+          sock = new Socket(resolveHost(host), port);
         } catch (ConnectException e) {
           log.debug("server not yet available; retrying ...");
           Thread.sleep(20);
@@ -1012,7 +1124,7 @@ public void submit(
       long timeout = System.nanoTime() + TimeUnit.NANOSECONDS.convert(5, TimeUnit.SECONDS);
       while (sock == null && System.nanoTime() <= timeout) {
         try {
-          sock = new Socket(host, port);
+          sock = new Socket(resolveHost(host), port);
         } catch (ConnectException e) {
           log.debug("server not yet available; retrying ...");
           Thread.sleep(20);
@@ -1114,13 +1226,31 @@ public void sendEvent(String name) throws IOException {
   public synchronized void close() throws IOException {
     // Mark as disconnected to prevent shutdown hook from attempting further sends
     disconnected = true;
-    if (protocol != null) {
-      protocol.close();
+    IOException failure = null;
+    try {
+      if (protocol != null) {
+        protocol.close();
+      }
+    } catch (IOException e) {
+      failure = e;
     }
-    if (sock != null) {
-      sock.close();
+    try {
+      if (sock != null) {
+        sock.close();
+      }
+    } catch (IOException e) {
+      if (failure == null) {
+        failure = e;
+      } else {
+        failure.addSuppressed(e);
+      }
+    } finally {
+      reset();
+      replaceAuthenticationToken(null);
+    }
+    if (failure != null) {
+      throw failure;
     }
-    reset();
   }
 
   public boolean isDisconnected() {
diff --git a/btrace-client/src/main/java/io/btrace/client/Main.java b/btrace-client/src/main/java/io/btrace/client/Main.java
index 1e969186f..a3d05b7c5 100644
--- a/btrace-client/src/main/java/io/btrace/client/Main.java
+++ b/btrace-client/src/main/java/io/btrace/client/Main.java
@@ -272,6 +272,9 @@ public static void main(String[] args) throws Exception {
               DUMP_DIR,
               statsdDef);
       if (resumeProbe != null) {
+        if (!hostDefined) {
+          client.discoverPreparedAgent(pid.toString());
+        }
         registerExitHook(client);
         if (con != null) {
           registerSignalHandler(client);
diff --git a/btrace-core/src/main/java/io/btrace/core/Args.java b/btrace-core/src/main/java/io/btrace/core/Args.java
index fa054c412..e21efca55 100644
--- a/btrace-core/src/main/java/io/btrace/core/Args.java
+++ b/btrace-core/src/main/java/io/btrace/core/Args.java
@@ -37,6 +37,8 @@ public final class Args {
   public static final String PROBE_DESC_PATH = "probeDescPath";
   public static final String DEBUG = "debug";
   public static final String TELEMETRY = "telemetry";
+  public static final String BIND_ADDRESS = "bindAddress";
+  public static final String AUTH_TOKEN_FILE = "authTokenFile";
   public static final String PORT = "port";
   public static final String STDOUT = "stdout";
   public static final String NO_SERVER = "noServer";
diff --git a/btrace-core/src/main/java/io/btrace/core/comm/ConnectionAuthenticator.java b/btrace-core/src/main/java/io/btrace/core/comm/ConnectionAuthenticator.java
new file mode 100644
index 000000000..0a1fc4393
--- /dev/null
+++ b/btrace-core/src/main/java/io/btrace/core/comm/ConnectionAuthenticator.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.core.comm;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+/** Performs the prepared-mode authentication preamble before V1 or V2 protocol negotiation. */
+public final class ConnectionAuthenticator {
+  public static final int MAX_TOKEN_LENGTH = 4096;
+
+  private static final byte[] REQUEST_MAGIC = {'B', 'T', 'A', '1'};
+  private static final byte[] ACCEPT_MAGIC = {'B', 'T', 'A', 'K'};
+  private static final byte[] REJECT_MAGIC = {'B', 'T', 'A', 'F'};
+
+  private ConnectionAuthenticator() {}
+
+  public static void authenticateClient(InputStream input, OutputStream output, byte[] token)
+      throws IOException {
+    validateToken(token);
+
+    DataOutputStream dataOutput = new DataOutputStream(output);
+    dataOutput.write(REQUEST_MAGIC);
+    dataOutput.writeInt(token.length);
+    dataOutput.write(token);
+    dataOutput.flush();
+
+    byte[] response = new byte[ACCEPT_MAGIC.length];
+    if (readFully(input, response) != response.length
+        || !MessageDigest.isEqual(ACCEPT_MAGIC, response)) {
+      throw new IOException("Prepared-mode authentication rejected");
+    }
+  }
+
+  public static void authenticateAgent(InputStream input, OutputStream output, byte[] expectedToken)
+      throws IOException {
+    validateToken(expectedToken);
+
+    byte[] suppliedToken = null;
+    try {
+      byte[] requestMagic = new byte[REQUEST_MAGIC.length];
+      if (readFully(input, requestMagic) != requestMagic.length
+          || !MessageDigest.isEqual(REQUEST_MAGIC, requestMagic)) {
+        throw new IOException("Invalid authentication preamble");
+      }
+
+      int tokenLength = new DataInputStream(input).readInt();
+      if (tokenLength < 1 || tokenLength > MAX_TOKEN_LENGTH) {
+        throw new IOException("Invalid authentication token length");
+      }
+
+      suppliedToken = new byte[tokenLength];
+      if (readFully(input, suppliedToken) != suppliedToken.length
+          || !MessageDigest.isEqual(expectedToken, suppliedToken)) {
+        throw new IOException("Invalid authentication token");
+      }
+
+      output.write(ACCEPT_MAGIC);
+      output.flush();
+    } catch (IOException | RuntimeException failure) {
+      writeRejection(output);
+      throw new IOException("Prepared-mode authentication failed");
+    } finally {
+      if (suppliedToken != null) {
+        Arrays.fill(suppliedToken, (byte) 0);
+      }
+    }
+  }
+
+  public static byte[] readToken(Path path) throws IOException {
+    byte[] fileBytes = new byte[MAX_TOKEN_LENGTH + 3];
+    int length = 0;
+    try (InputStream input =
+        Files.newInputStream(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) {
+      while (length < fileBytes.length) {
+        int read = input.read(fileBytes, length, fileBytes.length - length);
+        if (read < 0) {
+          break;
+        }
+        length += read;
+      }
+      if (length == fileBytes.length) {
+        throw invalidToken();
+      }
+
+      int tokenLength = length;
+      if (tokenLength > 0 && fileBytes[tokenLength - 1] == '\n') {
+        tokenLength--;
+        if (tokenLength > 0 && fileBytes[tokenLength - 1] == '\r') {
+          tokenLength--;
+        }
+      }
+      if (tokenLength < 1 || tokenLength > MAX_TOKEN_LENGTH) {
+        throw invalidToken();
+      }
+      for (int i = 0; i < tokenLength; i++) {
+        if (fileBytes[i] == '\r' || fileBytes[i] == '\n') {
+          throw invalidToken();
+        }
+      }
+
+      byte[] token = Arrays.copyOf(fileBytes, tokenLength);
+      validateToken(token);
+      return token;
+    } finally {
+      Arrays.fill(fileBytes, (byte) 0);
+    }
+  }
+
+  private static void validateToken(byte[] token) throws IOException {
+    if (token == null || token.length < 1 || token.length > MAX_TOKEN_LENGTH) {
+      throw invalidToken();
+    }
+  }
+
+  private static IOException invalidToken() {
+    return new IOException("Prepared-mode authentication token is invalid");
+  }
+
+  private static void writeRejection(OutputStream output) {
+    try {
+      output.write(REJECT_MAGIC);
+      output.flush();
+    } catch (IOException ignored) {
+    }
+  }
+
+  private static int readFully(InputStream input, byte[] buffer) throws IOException {
+    int offset = 0;
+    while (offset < buffer.length) {
+      int read = input.read(buffer, offset, buffer.length - offset);
+      if (read < 0) {
+        break;
+      }
+      if (read == 0) {
+        int value = input.read();
+        if (value < 0) {
+          break;
+        }
+        buffer[offset++] = (byte) value;
+        continue;
+      }
+      offset += read;
+    }
+    return offset;
+  }
+}
diff --git a/docs/BTraceTutorial.md b/docs/BTraceTutorial.md
index 3be8654f6..35810df6d 100644
--- a/docs/BTraceTutorial.md
+++ b/docs/BTraceTutorial.md
@@ -118,6 +118,9 @@ Once you are attached to the target JVM you can press Ctrl-C in the terminal to
 The agent takes a list of comma separated arguments.
 
 * **noServer** - don't start the socket server
+* **port** - command-server port; prepared mode accepts `0` for an automatically selected port
+* **bindAddress** - explicit loopback bind address; non-loopback and wildcard addresses fail closed
+* **authTokenFile** - existing owner-protected token file, or a path where the agent creates one
 * **bootClassPath** - boot classpath to be used
 * **systemClassPath** - system classpath to be used
 * **debug** - turns on verbose debug messages (true/false)
@@ -138,9 +141,19 @@ The agent takes a list of comma separated arguments.
 The scripts to be run must have already been compiled to bytecode (a *.class* file) by __btracec__.
 
 For a launch-time script that needs no later client connection, set `noServer=true`. BTrace 3.0
-does not treat an unauthenticated startup command server as prepared mode; prepared mode requires
-authentication and loopback-only binding. Until that path is available in a tested release, use
-startup-script mode with `noServer=true` or dynamic attach where JVM policy permits it.
+does not treat an unauthenticated startup command server as prepared mode. To prepare a JVM for a
+later PID-based BTrace connection, start the agent without a script:
+
+```bash
+java -javaagent:btrace.jar=port=0 MyApp
+```
+
+The agent binds loopback, creates owner-protected credentials, and publishes discovery metadata
+through the target JVM. The 3.0 client reads that metadata through the Attach API, authenticates
+before V1/V2 negotiation, and never places the token in arguments, properties, or logs. Set
+`noServer=false` explicitly if a startup script must also retain the authenticated endpoint.
+Generated credentials are removed at shutdown. Older clients fail closed, and remote prepared mode
+is unsupported in 3.0.0.
 
 ###### Using `btracer'
 
diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md
index 7d07b6520..feb6e9d03 100644
--- a/docs/GettingStarted.md
+++ b/docs/GettingStarted.md
@@ -382,12 +382,27 @@ BTrace 3.0 distinguishes launch-time startup scripts from prepared mode:
   ```
 
 - **Prepared mode** is required to start an authenticated, loopback-only control endpoint for
-  later probe submission. The 3.0.0 policy permits neither unauthenticated commands nor
-  non-loopback binding.
+  later probe submission. Start the agent without a startup script and let the operating system
+  choose the port:
 
-Prepared mode remains a 3.0.0 release gate. Until the authenticated path is available in a tested
-release, use startup-script mode with `noServer=true`, or use dynamic attach where the target JVM's
-policy permits it. Do not expose a startup command server as a remote diagnostic endpoint.
+  ```bash
+  java -javaagent:/path/to/btrace.jar=port=0 MyApp
+  ```
+
+  The agent binds a loopback address, generates an owner-protected token file, and publishes the
+  actual address, port, and token-file path through target JVM properties. A 3.0 client discovers
+  those values through the Attach API when you run `btrace <pid> ...`; the token itself is never a
+  process argument or system property. Generated token files are removed during orderly shutdown.
+
+  Use `authTokenFile=/owner/protected/path` to supply or create credentials at a controlled path,
+  and `bindAddress=127.0.0.1` or `bindAddress=::1` to select a loopback family. Non-loopback and
+  wildcard addresses fail startup. To keep a startup script and accept later authenticated
+  connections, set `noServer=false` explicitly.
+
+Prepared-mode authentication runs before V1 or V2 negotiation and before every operational
+command. Older clients cannot connect to a 3.0 prepared endpoint. Remote prepared-mode operation
+is not supported in 3.0.0; use startup-script mode with `noServer=true` or dynamic attach where the
+target JVM's policy permits it instead of exposing the command server remotely.
 
 #### Optional agent telemetry
 
diff --git a/docs/QuickReference.md b/docs/QuickReference.md
index a1c9e41a3..a09fb4046 100644
--- a/docs/QuickReference.md
+++ b/docs/QuickReference.md
@@ -538,7 +538,11 @@ java -javaagent:/path/to/btrace.jar=script=<script.class>,noServer=true[,arg=val
 **Agent Parameters:**
 - `script=<path>` - BTrace script class file
 - `scriptdir=<dir>` - Directory to load scripts from
-- `port=<port>` - Communication port
+- `port=<port>` - Communication port; prepared mode accepts `0` and publishes the selected port
+- `bindAddress=<address>` - Loopback bind address (`127.0.0.1` or `::1`); non-loopback and wildcard
+  addresses are rejected in 3.0.0
+- `authTokenFile=<path>` - Existing owner-protected prepared-mode token file, or a path where the
+  agent creates one; the token value is never passed as an argument
 - `noServer=true` - Don't start the command server; use for startup scripts that need no later
   client connection
 - `telemetry=true|false` - Explicitly enable or disable the default-off `agent_start` telemetry
@@ -552,8 +556,19 @@ java -javaagent:btrace.jar=script=MyTrace.class,noServer=true MyApp
 
 # With debug logging and no command endpoint
 java -javaagent:btrace.jar=script=MyTrace.class,noServer=true,debug=true MyApp
+
+# Authenticated prepared endpoint on an automatically selected loopback port
+java -javaagent:btrace.jar=port=0 MyApp
+
+# Run a startup probe and retain the authenticated endpoint for later PID-based connections
+java -javaagent:btrace.jar=script=MyTrace.class,noServer=false,port=0 MyApp
 ```
 
+A 3.0 client obtains the prepared endpoint and token-file path through the target JVM's Attach API.
+Authentication precedes V1/V2 negotiation; older clients and missing or incorrect credentials fail
+closed. Generated credentials are removed at shutdown. Remote prepared mode is unsupported in
+3.0.0.
+
 ### Wire Protocol
 Client and agent communicate over a pluggable wire protocol: V1 (Java serialization, backward compatible) and V2 (binary, faster). The version is auto-negotiated by default. System properties:
 
diff --git a/docs/architecture/Version2ProtocolArchitecture.md b/docs/architecture/Version2ProtocolArchitecture.md
index 807614efb..e00faf2db 100644
--- a/docs/architecture/Version2ProtocolArchitecture.md
+++ b/docs/architecture/Version2ProtocolArchitecture.md
@@ -208,6 +208,24 @@ public interface WireProtocol {
 
 ## Protocol Negotiation
 
+### Prepared-mode authentication boundary
+
+When the agent was loaded at JVM startup with its command server enabled, authentication occurs
+before the V1/V2 choice described below. The client sends `BTA1`, a four-byte big-endian token
+length, and the token bytes. The agent responds with `BTAK` on success or `BTAF` on failure. Only
+after `BTAK` may the client send the V1 serialization header or the V2 `BTR2` prefix.
+
+Both sides apply the protocol-negotiation timeout to this preamble. Invalid lengths, truncated or
+incorrect tokens, and direct V1/V2 bytes are rejected before any command decoder is constructed.
+V2-to-V1 fallback opens a new connection and authenticates it again; authentication failure never
+causes an unauthenticated fallback. Dynamically attached agents retain the version-negotiation flow
+without this prepared-mode preamble for compatibility with 2.2.x clients.
+
+```text
+prepared: TCP connect -> BTA1 + length + token -> BTAK -> V1/V2 negotiation -> commands
+dynamic:  TCP connect -> V1/V2 negotiation -> commands
+```
+
 ### Design Principle: Once Per Connection
 
 **Critical:** Protocol negotiation happens **once** when a connection is established, not per command.
@@ -215,7 +233,8 @@ public interface WireProtocol {
 **Timeline:**
 ```
 Time 0ms:     TCP socket established
-Time 1ms:     Client sends magic bytes (BTR2) or v1 header
+Time 1ms:     Prepared mode authenticates; dynamic mode skips this step
+Time 2ms:     Client sends magic bytes (BTR2) or v1 header
 Time 5ms:     Agent responds with protocol acknowledgment
 Time 6ms:     Protocol locked for session (v1 or v2)
 Time 7ms:     First command sent (using negotiated protocol)
@@ -309,6 +328,9 @@ Socket sock = acceptConnection();
 InputStream in = sock.getInputStream();
 OutputStream out = sock.getOutputStream();
 
+// Prepared mode only; rejects before a protocol decoder exists
+ConnectionAuthenticator.authenticateAgent(in, out, expectedToken);
+
 // Negotiate protocol (reads first bytes)
 ProtocolVersion version = ProtocolNegotiator.negotiateAgent(in, out);
 
@@ -329,6 +351,9 @@ Socket sock = new Socket(host, port);
 InputStream in = sock.getInputStream();
 OutputStream out = sock.getOutputStream();
 
+// Prepared mode only; repeats on every fallback connection
+ConnectionAuthenticator.authenticateClient(in, out, token);
+
 // Negotiate protocol (sends magic bytes, waits for response)
 ProtocolVersion preferred = getPreferredVersion(); // from config
 ProtocolVersion version = ProtocolNegotiator.negotiateClient(in, out, preferred);
diff --git a/internal/specs/3.0.0-prepared-mode-authentication.md b/internal/specs/3.0.0-prepared-mode-authentication.md
new file mode 100644
index 000000000..bd101ee19
--- /dev/null
+++ b/internal/specs/3.0.0-prepared-mode-authentication.md
@@ -0,0 +1,104 @@
+# BTrace 3.0.0 Prepared-Mode Authentication Design
+
+Date: 2026-07-13
+
+Status: implementation design for
+[#874](https://github.com/btraceio/btrace/issues/874)
+
+Policy dependency:
+[`3.0.0-prepared-mode-security-policy.md`](3.0.0-prepared-mode-security-policy.md)
+
+## Scope
+
+Authentication protects the startup-loaded (`premain`) command server before existing V1 or V2
+protocol negotiation begins. Dynamically attached (`agentmain`) clients retain the existing
+protocol handshake for 2.2.x compatibility. Startup-script mode with `noServer=true` creates no
+server endpoint or credentials.
+
+Remote command-server operation is deferred. BTrace 3.0.0 accepts only loopback bind addresses;
+wildcard and non-loopback addresses fail agent initialization.
+
+## Version-neutral preamble
+
+Every prepared-mode TCP connection starts with this request:
+
+```text
+4 bytes  magic: ASCII "BTA1"
+4 bytes  unsigned token length encoded as a positive Java int
+N bytes  token
+```
+
+The token length must be between 1 and 4096 bytes. The server reads the request under the existing
+bounded negotiation socket timeout and compares the supplied token with
+`MessageDigest.isEqual`.
+
+The server writes exactly one four-byte response:
+
+```text
+ASCII "BTAK"  authentication accepted
+ASCII "BTAF"  authentication rejected
+```
+
+After `BTAK`, the connection proceeds with the unchanged V1 Java-serialization header or V2
+`BTR2` negotiation. Missing magic, invalid lengths, truncated tokens, and incorrect tokens receive
+`BTAF` when the socket remains writable, then the server closes the connection. No BTrace command
+is decoded before `BTAK`.
+
+This preamble deliberately is not a V1 or V2 command: putting authentication inside either command
+codec would require decoding an unauthenticated operational stream and would leave the other codec
+unprotected.
+
+## Compatibility
+
+- A 3.0 client dynamically attaching a 2.2.x agent sends no authentication preamble.
+- A 2.2.x client dynamically attaching a 3.0 agent uses the existing V1-compatible path.
+- A 2.2.x client connecting to a 3.0 prepared agent sends V1 or V2 bytes where `BTA1` is required;
+  the server rejects and closes it before decoding a command.
+- A 3.0 client reads prepared-mode discovery properties through the Attach API, authenticates, and
+  then negotiates V1 or V2 normally.
+
+Authentication failure never falls back to an unauthenticated connection. V2-to-V1 protocol
+fallback opens a new socket and repeats authentication before starting V1.
+
+## Endpoint and discovery properties
+
+After the socket is bound and credentials are ready, the agent publishes:
+
+| Property | Value |
+| --- | --- |
+| `btrace.port` | Actual nonzero bound port, including when configured with `port=0` |
+| `btrace.address` | Numeric loopback address used by the server |
+| `btrace.auth.required` | `true` for prepared mode; `false` for dynamic mode |
+| `btrace.auth.tokenFile` | Absolute token-file path for prepared mode only |
+
+The client adopts the discovered address and port for the selected target PID instead of guessing
+the IP family or assuming the configured port survived ephemeral binding.
+
+## Token lifecycle
+
+Prepared mode accepts an optional `authTokenFile=<path>` argument. The argument contains a path,
+never a token.
+
+- If the path names an existing regular file, the agent requires it to be owned by the target JVM's
+  account with owner-only access, then reads one non-empty token of at most 4096 bytes.
+- If the path does not exist, or no path is configured, the agent creates a file, writes a
+  cryptographically random 256-bit URL-safe token, and restricts access to the owner.
+- POSIX files use `rw-------`. ACL-backed files are restricted and verified through Java NIO. If
+  owner-only access cannot be established or verified, prepared-mode startup fails.
+- Agent-generated files are deleted on initialization failure and orderly shutdown. User-supplied
+  existing files remain in place.
+- In-memory token copies are cleared when their owner closes or after a connection authenticates.
+
+Token contents are excluded from process arguments, system properties, normal logs, debug logs,
+and exceptions. Discovery publishes only the file path.
+
+## Server lifecycle
+
+Socket binding and credential preparation occur synchronously during agent initialization. The
+accept thread starts only after the runtime is ready. Any later initialization failure closes the
+socket, clears discovery properties, and removes generated credentials.
+
+Each accepted prepared-mode socket authenticates under the bounded negotiation timeout. Failed
+connections are closed before the accept loop continues. Shutdown closes the listening socket,
+clears the discovery properties, wipes the in-memory token, and removes generated credential
+material.


Test patch:
diff --git a/btrace-agent/src/test/java/io/btrace/agent/ControlServerTest.java b/btrace-agent/src/test/java/io/btrace/agent/ControlServerTest.java
new file mode 100644
index 000000000..785a1ff22
--- /dev/null
+++ b/btrace-agent/src/test/java/io/btrace/agent/ControlServerTest.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.btrace.core.ArgsMap;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+class ControlServerTest {
+  @AfterEach
+  void clearDiscoveryProperties() {
+    System.clearProperty(ControlServer.PORT_PROPERTY);
+    System.clearProperty(ControlServer.ADDRESS_PROPERTY);
+    System.clearProperty(ControlServer.AUTH_REQUIRED_PROPERTY);
+    System.clearProperty(ControlServer.TOKEN_FILE_PROPERTY);
+  }
+
+  @Test
+  void preparedModePublishesEphemeralLoopbackEndpointAndRemovesCredentials() throws Exception {
+    ControlServer server = ControlServer.open(new ArgsMap(new String[] {"port=0"}), true);
+    Path tokenFile = Paths.get(System.getProperty(ControlServer.TOKEN_FILE_PROPERTY));
+    try {
+      assertTrue(server.getAddress().isLoopbackAddress());
+      assertTrue(server.getPort() > 0);
+      assertEquals(
+          String.valueOf(server.getPort()), System.getProperty(ControlServer.PORT_PROPERTY));
+      assertEquals("true", System.getProperty(ControlServer.AUTH_REQUIRED_PROPERTY));
+      assertTrue(Files.isRegularFile(tokenFile));
+      assertTrue(server.copyAuthenticationToken().length > 0);
+    } finally {
+      server.close();
+    }
+
+    assertTrue(server.isClosed());
+    assertFalse(Files.exists(tokenFile));
+    assertNull(System.getProperty(ControlServer.PORT_PROPERTY));
+    assertNull(System.getProperty(ControlServer.TOKEN_FILE_PROPERTY));
+  }
+
+  @Test
+  void dynamicModeUsesLoopbackWithoutAuthentication() throws Exception {
+    ControlServer server = ControlServer.open(new ArgsMap(new String[] {"port=0"}), false);
+    try {
+      assertTrue(server.getAddress().isLoopbackAddress());
+      assertEquals("false", System.getProperty(ControlServer.AUTH_REQUIRED_PROPERTY));
+      assertNull(System.getProperty(ControlServer.TOKEN_FILE_PROPERTY));
+      assertNull(server.copyAuthenticationToken());
+    } finally {
+      server.close();
+    }
+  }
+
+  @Test
+  void wildcardBindingFailsClosed() {
+    ArgsMap args = new ArgsMap(new String[] {"port=0", "bindAddress=0.0.0.0"});
+
+    assertThrows(IOException.class, () -> ControlServer.open(args, true));
+    assertNull(System.getProperty(ControlServer.PORT_PROPERTY));
+  }
+
+  @Test
+  void explicitIpv4LoopbackIsSupported() throws Exception {
+    ArgsMap args = new ArgsMap(new String[] {"port=0", "bindAddress=127.0.0.1"});
+
+    try (ControlServer server = ControlServer.open(args, true)) {
+      assertEquals(InetAddress.getByName("127.0.0.1"), server.getAddress());
+    }
+  }
+
+  @Test
+  void explicitIpv6LoopbackIsSupportedWhenAvailable() throws Exception {
+    InetAddress ipv6 = InetAddress.getByName("::1");
+    Assumptions.assumeTrue(NetworkInterface.getByInetAddress(ipv6) != null);
+    ArgsMap args = new ArgsMap(new String[] {"port=0", "bindAddress=::1"});
+
+    try (ControlServer server = ControlServer.open(args, true)) {
+      assertTrue(server.getAddress().isLoopbackAddress());
+      assertEquals(ipv6, server.getAddress());
+    }
+  }
+
+  @Test
+  void invalidPortFailsClosed() {
+    assertThrows(
+        IOException.class,
+        () -> ControlServer.open(new ArgsMap(new String[] {"port=70000"}), true));
+  }
+}
diff --git a/btrace-agent/src/test/java/io/btrace/agent/MainTest.java b/btrace-agent/src/test/java/io/btrace/agent/MainTest.java
index 53e90b410..e75211922 100644
--- a/btrace-agent/src/test/java/io/btrace/agent/MainTest.java
+++ b/btrace-agent/src/test/java/io/btrace/agent/MainTest.java
@@ -17,6 +17,7 @@
 package io.btrace.agent;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import io.btrace.core.ArgsMap;
@@ -59,4 +60,20 @@ void locateScriptsDir() throws Exception {
 
     assertEquals(1, scripts.size());
   }
+
+  @Test
+  void noServerArgumentDisablesEndpoint() {
+    assertFalse(Main.shouldStartServer(new ArgsMap(new String[] {"noServer=true"})));
+  }
+
+  @Test
+  void startupScriptDefaultsToNoServer() {
+    assertFalse(Main.shouldStartServer(new ArgsMap(new String[] {"script=probe.class"})));
+  }
+
+  @Test
+  void explicitServerOverridesStartupScriptDefault() {
+    assertTrue(
+        Main.shouldStartServer(new ArgsMap(new String[] {"script=probe.class", "noServer=false"})));
+  }
 }
diff --git a/btrace-agent/src/test/java/io/btrace/agent/PreparedModeCredentialsTest.java b/btrace-agent/src/test/java/io/btrace/agent/PreparedModeCredentialsTest.java
new file mode 100644
index 000000000..991d2e502
--- /dev/null
+++ b/btrace-agent/src/test/java/io/btrace/agent/PreparedModeCredentialsTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileStore;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.nio.file.attribute.UserPrincipal;
+import java.util.Set;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class PreparedModeCredentialsTest {
+  @TempDir Path tempDir;
+
+  @Test
+  void generatedTokenIsOwnerProtectedAndDeleted() throws Exception {
+    PreparedModeCredentials credentials = PreparedModeCredentials.create(null);
+    Path path = credentials.getPath();
+    try {
+      assertTrue(Files.isRegularFile(path));
+      assertTrue(credentials.copyToken().length >= 32);
+      if (supportsPosix(path)) {
+        Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
+        assertTrue(permissions.contains(PosixFilePermission.OWNER_READ));
+        assertTrue(permissions.contains(PosixFilePermission.OWNER_WRITE));
+        assertFalse(permissions.contains(PosixFilePermission.GROUP_READ));
+        assertFalse(permissions.contains(PosixFilePermission.OTHERS_READ));
+      }
+    } finally {
+      credentials.close();
+    }
+    assertFalse(Files.exists(path));
+  }
+
+  @Test
+  void existingOwnerProtectedTokenIsPreserved() throws Exception {
+    Assumptions.assumeTrue(supportsPosix(tempDir));
+    Path path = tempDir.resolve("existing.token");
+    byte[] token = "existing-secret".getBytes(StandardCharsets.UTF_8);
+    Files.write(path, token);
+    Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
+
+    PreparedModeCredentials credentials = PreparedModeCredentials.create(path.toString());
+    try {
+      assertArrayEquals(token, credentials.copyToken());
+    } finally {
+      credentials.close();
+    }
+    assertTrue(Files.exists(path));
+  }
+
+  @Test
+  void existingGroupReadableTokenFailsClosed() throws Exception {
+    Assumptions.assumeTrue(supportsPosix(tempDir));
+    Path path = tempDir.resolve("insecure.token");
+    Files.write(path, "secret".getBytes(StandardCharsets.UTF_8));
+    Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-r-----"));
+
+    assertThrows(IOException.class, () -> PreparedModeCredentials.create(path.toString()));
+  }
+
+  @Test
+  void existingTokenOwnedByAnotherAccountFailsClosed() throws Exception {
+    Path path = tempDir.resolve("foreign.token");
+    Files.write(path, "known-secret".getBytes(StandardCharsets.UTF_8));
+    if (supportsPosix(path)) {
+      Files.setPosixFilePermissions(path, PosixFilePermissions.fromString("rw-------"));
+    }
+    UserPrincipal anotherAccount = () -> "another-account";
+
+    IOException failure =
+        assertThrows(
+            IOException.class,
+            () -> PreparedModeCredentials.create(path.toString(), anotherAccount));
+    assertTrue(failure.getMessage().contains("owned by another account"));
+  }
+
+  @Test
+  void symbolicLinkTokenFailsClosed() throws Exception {
+    Path target = tempDir.resolve("target.token");
+    Path link = tempDir.resolve("link.token");
+    Files.write(target, "secret".getBytes(StandardCharsets.UTF_8));
+    try {
+      Files.createSymbolicLink(link, target);
+    } catch (IOException | UnsupportedOperationException unsupported) {
+      Assumptions.assumeTrue(false, "symbolic links are unavailable");
+    }
+
+    assertThrows(IOException.class, () -> PreparedModeCredentials.create(link.toString()));
+  }
+
+  private static boolean supportsPosix(Path path) throws IOException {
+    FileStore store = Files.getFileStore(path);
+    return store.supportsFileAttributeView(PosixFileAttributeView.class);
+  }
+}
diff --git a/btrace-agent/src/test/java/io/btrace/agent/RemoteClientAuthenticationTest.java b/btrace-agent/src/test/java/io/btrace/agent/RemoteClientAuthenticationTest.java
new file mode 100644
index 000000000..a861d48ea
--- /dev/null
+++ b/btrace-agent/src/test/java/io/btrace/agent/RemoteClientAuthenticationTest.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.agent;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.btrace.core.ArgsMap;
+import io.btrace.core.SharedSettings;
+import io.btrace.core.comm.BinaryWireProtocol;
+import io.btrace.core.comm.Command;
+import io.btrace.core.comm.ConnectionAuthenticator;
+import io.btrace.core.comm.ExitCommand;
+import io.btrace.core.comm.JavaSerializationProtocol;
+import io.btrace.core.comm.ListProbesCommand;
+import io.btrace.core.comm.ProtocolNegotiator;
+import io.btrace.core.comm.ProtocolVersion;
+import io.btrace.core.comm.WireProtocol;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class RemoteClientAuthenticationTest {
+  private static final byte[] TOKEN = "prepared-secret".getBytes(StandardCharsets.UTF_8);
+
+  @AfterEach
+  void clearTimeout() {
+    System.clearProperty("btrace.protocol.negotiation.timeout");
+  }
+
+  @Test
+  void authenticatedV1CanListProbes() throws Exception {
+    runAuthenticatedListSession(ProtocolVersion.V1);
+  }
+
+  @Test
+  void authenticatedV2CanListProbes() throws Exception {
+    runAuthenticatedListSession(ProtocolVersion.V2);
+  }
+
+  @Test
+  void incorrectCredentialIsRejectedBeforeClientContext() throws Exception {
+    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+      ExecutorService executor = Executors.newSingleThreadExecutor();
+      Future<?> accepted =
+          executor.submit(
+              () -> {
+                try (Socket socket = server.accept()) {
+                  assertThrows(
+                      IOException.class,
+                      () -> RemoteClient.getClient(null, socket, TOKEN.clone(), client -> null));
+                } catch (IOException e) {
+                  throw new RuntimeException(e);
+                }
+              });
+      try (Socket socket = new Socket(server.getInetAddress(), server.getLocalPort())) {
+        assertThrows(
+            IOException.class,
+            () ->
+                ConnectionAuthenticator.authenticateClient(
+                    socket.getInputStream(),
+                    socket.getOutputStream(),
+                    "wrong-secret".getBytes(StandardCharsets.UTF_8)));
+      } finally {
+        accepted.get(5, TimeUnit.SECONDS);
+        executor.shutdownNow();
+      }
+    }
+  }
+
+  @Test
+  void incompleteAuthenticationIsBounded() throws Exception {
+    System.setProperty("btrace.protocol.negotiation.timeout", "100");
+    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+      ExecutorService executor = Executors.newSingleThreadExecutor();
+      Future<?> accepted =
+          executor.submit(
+              () -> {
+                try (Socket socket = server.accept()) {
+                  assertThrows(
+                      IOException.class,
+                      () -> RemoteClient.getClient(null, socket, TOKEN.clone(), client -> null));
+                } catch (IOException e) {
+                  throw new RuntimeException(e);
+                }
+              });
+      try (Socket ignored = new Socket(server.getInetAddress(), server.getLocalPort())) {
+        accepted.get(5, TimeUnit.SECONDS);
+      } finally {
+        executor.shutdownNow();
+      }
+    }
+  }
+
+  private static void runAuthenticatedListSession(ProtocolVersion version) throws Exception {
+    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+      ExecutorService executor = Executors.newSingleThreadExecutor();
+      ClientContext context = new ClientContext(null, null, new ArgsMap(), new SharedSettings());
+      Future<Client> accepted =
+          executor.submit(
+              () -> {
+                try (Socket socket = server.accept()) {
+                  return RemoteClient.getClient(context, socket, TOKEN.clone(), client -> null);
+                }
+              });
+
+      try (Socket socket = new Socket(server.getInetAddress(), server.getLocalPort())) {
+        ConnectionAuthenticator.authenticateClient(
+            socket.getInputStream(), socket.getOutputStream(), TOKEN);
+        try (WireProtocol protocol = createClientProtocol(version, socket)) {
+          protocol.write(new ListProbesCommand());
+          protocol.flush();
+          Command response = protocol.read();
+          assertTrue(response instanceof ListProbesCommand);
+          protocol.write(new ExitCommand(0));
+          protocol.flush();
+        }
+      }
+
+      try {
+        assertNull(accepted.get(5, TimeUnit.SECONDS));
+      } catch (ExecutionException e) {
+        Throwable cause = e.getCause();
+        if (cause instanceof Exception) {
+          throw (Exception) cause;
+        }
+        throw e;
+      } finally {
+        executor.shutdownNow();
+      }
+    }
+  }
+
+  private static WireProtocol createClientProtocol(ProtocolVersion version, Socket socket)
+      throws Exception {
+    if (version == ProtocolVersion.V1) {
+      return new JavaSerializationProtocol(socket.getInputStream(), socket.getOutputStream());
+    }
+    ProtocolNegotiator negotiator = new ProtocolNegotiator(ProtocolVersion.V2);
+    assertEquals(
+        ProtocolVersion.V2,
+        negotiator.negotiateClient(
+            socket.getInputStream(), socket.getOutputStream(), ProtocolVersion.V2));
+    return new BinaryWireProtocol(socket.getInputStream(), socket.getOutputStream());
+  }
+}
diff --git a/btrace-client/src/test/java/io/btrace/client/ClientTest.java b/btrace-client/src/test/java/io/btrace/client/ClientTest.java
index adea73217..99151f856 100644
--- a/btrace-client/src/test/java/io/btrace/client/ClientTest.java
+++ b/btrace-client/src/test/java/io/btrace/client/ClientTest.java
@@ -22,7 +22,19 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.lang.reflect.Field;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
 import java.util.jar.JarOutputStream;
@@ -146,4 +158,131 @@ void testBackwardCompatibility() {
       fail("Failed to verify backward compatibility: " + e.getMessage());
     }
   }
+
+  @Test
+  void preparedDiscoveryAdoptsEndpointAndCredentials() throws Exception {
+    Path tokenFile = tempDir.resolve("prepared.token");
+    byte[] token = "prepared-secret".getBytes(StandardCharsets.UTF_8);
+    Files.write(tokenFile, token);
+    Properties properties = new Properties();
+    properties.setProperty("btrace.port", "43210");
+    properties.setProperty("btrace.address", "::1");
+    properties.setProperty("btrace.auth.required", "true");
+    properties.setProperty("btrace.auth.tokenFile", tokenFile.toString());
+    Client client = new Client(0);
+
+    client.configureConnection(properties);
+
+    assertEquals(43210, readField(client, "port"));
+    assertEquals(InetAddress.getByName("::1").getHostAddress(), client.resolveHost("localhost"));
+    assertArrayEquals(token, (byte[]) readField(client, "authenticationToken"));
+  }
+
+  @Test
+  void missingPreparedCredentialsFailClosed() {
+    Properties properties = new Properties();
+    properties.setProperty("btrace.port", "43210");
+    properties.setProperty("btrace.auth.required", "true");
+
+    assertThrows(IOException.class, () -> new Client(0).configureConnection(properties));
+  }
+
+  @Test
+  void nonLoopbackDiscoveryFailsWithoutReplacingExistingConnection() throws Exception {
+    Path tokenFile = tempDir.resolve("prepared.token");
+    byte[] token = "prepared-secret".getBytes(StandardCharsets.UTF_8);
+    Files.write(tokenFile, token);
+    Properties valid = new Properties();
+    valid.setProperty("btrace.port", "43210");
+    valid.setProperty("btrace.address", "127.0.0.1");
+    valid.setProperty("btrace.auth.required", "true");
+    valid.setProperty("btrace.auth.tokenFile", tokenFile.toString());
+    Client client = new Client(0);
+    client.configureConnection(valid);
+
+    Properties invalid = new Properties();
+    invalid.setProperty("btrace.port", "12345");
+    invalid.setProperty("btrace.address", "192.0.2.1");
+    invalid.setProperty("btrace.auth.required", "false");
+
+    assertThrows(IOException.class, () -> client.configureConnection(invalid));
+    assertEquals(43210, readField(client, "port"));
+    assertEquals("127.0.0.1", client.resolveHost("localhost"));
+    assertArrayEquals(token, (byte[]) readField(client, "authenticationToken"));
+  }
+
+  @Test
+  void preparedAuthenticationResponseIsTimeoutBounded() throws Exception {
+    Path tokenFile = tempDir.resolve("prepared.token");
+    byte[] token = "prepared-secret".getBytes(StandardCharsets.UTF_8);
+    Files.write(tokenFile, token);
+    CountDownLatch releaseServer = new CountDownLatch(1);
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    System.setProperty("btrace.protocol.negotiation.timeout", "100");
+    try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) {
+      Future<?> accepted =
+          executor.submit(
+              () -> {
+                try (Socket socket = server.accept()) {
+                  byte[] request = new byte[8 + token.length];
+                  int offset = 0;
+                  while (offset < request.length) {
+                    int read =
+                        socket.getInputStream().read(request, offset, request.length - offset);
+                    if (read < 0) {
+                      break;
+                    }
+                    offset += read;
+                  }
+                  releaseServer.await(5, TimeUnit.SECONDS);
+                } catch (Exception e) {
+                  throw new RuntimeException(e);
+                }
+              });
+      Properties properties = new Properties();
+      properties.setProperty("btrace.port", String.valueOf(server.getLocalPort()));
+      properties.setProperty("btrace.address", server.getInetAddress().getHostAddress());
+      properties.setProperty("btrace.auth.required", "true");
+      properties.setProperty("btrace.auth.tokenFile", tokenFile.toString());
+      Client client = new Client(0);
+      client.configureConnection(properties);
+
+      assertThrows(
+          SocketTimeoutException.class,
+          () -> client.connectAndListProbes("localhost", command -> {}));
+      releaseServer.countDown();
+      accepted.get(5, TimeUnit.SECONDS);
+      client.close();
+    } finally {
+      releaseServer.countDown();
+      executor.shutdownNow();
+      System.clearProperty("btrace.protocol.negotiation.timeout");
+    }
+  }
+
+  @Test
+  void closeClearsDiscoveredCredentials() throws Exception {
+    Path tokenFile = tempDir.resolve("prepared.token");
+    Files.write(tokenFile, "prepared-secret".getBytes(StandardCharsets.UTF_8));
+    Properties properties = new Properties();
+    properties.setProperty("btrace.port", "43210");
+    properties.setProperty("btrace.auth.required", "true");
+    properties.setProperty("btrace.auth.tokenFile", tokenFile.toString());
+    Client client = new Client(0);
+    client.configureConnection(properties);
+    byte[] token = (byte[]) readField(client, "authenticationToken");
+
+    client.close();
+
+    assertNull(readField(client, "authenticationToken"));
+    for (byte value : token) {
+      assertEquals(0, value);
+    }
+  }
+
+  private static Object readField(Client client, String name) throws Exception {
+    Field field = Client.class.getDeclaredField(name);
+    field.setAccessible(true);
+    return field.get(client);
+  }
 }
diff --git a/btrace-core/src/test/java/io/btrace/core/comm/ConnectionAuthenticatorTest.java b/btrace-core/src/test/java/io/btrace/core/comm/ConnectionAuthenticatorTest.java
new file mode 100644
index 000000000..0a37fd2cc
--- /dev/null
+++ b/btrace-core/src/test/java/io/btrace/core/comm/ConnectionAuthenticatorTest.java
@@ -0,0 +1,250 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.btrace.core.comm;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.io.PushbackInputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ConnectionAuthenticatorTest {
+  private static final byte[] TOKEN = "prepared-secret".getBytes(StandardCharsets.UTF_8);
+
+  @TempDir Path tempDir;
+
+  @Test
+  void authenticatesBeforeV1Commands() throws Exception {
+    runAuthenticatedSession(ProtocolVersion.V1);
+  }
+
+  @Test
+  void authenticatesBeforeV2Commands() throws Exception {
+    runAuthenticatedSession(ProtocolVersion.V2);
+  }
+
+  @Test
+  void incorrectTokenIsRejected() throws Exception {
+    Duplex duplex = new Duplex();
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    Future<?> server =
+        executor.submit(
+            () -> {
+              assertThrows(
+                  IOException.class,
+                  () ->
+                      ConnectionAuthenticator.authenticateAgent(
+                          duplex.serverIn, duplex.serverOut, TOKEN));
+            });
+    try {
+      assertThrows(
+          IOException.class,
+          () ->
+              ConnectionAuthenticator.authenticateClient(
+                  duplex.clientIn,
+                  duplex.clientOut,
+                  "wrong-secret".getBytes(StandardCharsets.UTF_8)));
+      server.get(5, TimeUnit.SECONDS);
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  @Test
+  void protocolBytesWithoutCredentialsAreRejected() {
+    ByteArrayOutputStream response = new ByteArrayOutputStream();
+
+    assertThrows(
+        IOException.class,
+        () ->
+            ConnectionAuthenticator.authenticateAgent(
+                new ByteArrayInputStream(ProtocolVersion.V2.getMagicBytes()), response, TOKEN));
+
+    assertArrayEquals(new byte[] {'B', 'T', 'A', 'F'}, response.toByteArray());
+  }
+
+  @Test
+  void malformedTokenLengthIsRejected() throws Exception {
+    ByteArrayOutputStream request = new ByteArrayOutputStream();
+    DataOutputStream data = new DataOutputStream(request);
+    data.write(new byte[] {'B', 'T', 'A', '1'});
+    data.writeInt(ConnectionAuthenticator.MAX_TOKEN_LENGTH + 1);
+    ByteArrayOutputStream response = new ByteArrayOutputStream();
+
+    assertThrows(
+        IOException.class,
+        () ->
+            ConnectionAuthenticator.authenticateAgent(
+                new ByteArrayInputStream(request.toByteArray()), response, TOKEN));
+
+    assertArrayEquals(new byte[] {'B', 'T', 'A', 'F'}, response.toByteArray());
+  }
+
+  @Test
+  void tokenFileAllowsOneTrailingNewline() throws Exception {
+    Path tokenFile = tempDir.resolve("token");
+    Files.write(tokenFile, "prepared-secret\n".getBytes(StandardCharsets.UTF_8));
+
+    assertArrayEquals(TOKEN, ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  @Test
+  void tokenFileAllowsOneTrailingWindowsNewline() throws Exception {
+    Path tokenFile = tempDir.resolve("windows-token");
+    Files.write(tokenFile, "prepared-secret\r\n".getBytes(StandardCharsets.UTF_8));
+
+    assertArrayEquals(TOKEN, ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  @Test
+  void tokenFilePreservesSpaces() throws Exception {
+    Path tokenFile = tempDir.resolve("spaced-token");
+    byte[] token = " prepared secret ".getBytes(StandardCharsets.UTF_8);
+    Files.write(tokenFile, token);
+
+    assertArrayEquals(token, ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  @Test
+  void tokenFileRejectsMultipleLines() throws Exception {
+    Path tokenFile = tempDir.resolve("multi-line-token");
+    Files.write(tokenFile, "prepared-secret\nsecond\n".getBytes(StandardCharsets.UTF_8));
+
+    assertThrows(IOException.class, () -> ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  @Test
+  void tokenFileRejectsOversizedContentBeforeAllocatingFromItsSize() throws Exception {
+    Path tokenFile = tempDir.resolve("oversized-token");
+    byte[] content = new byte[ConnectionAuthenticator.MAX_TOKEN_LENGTH + 3];
+    Arrays.fill(content, (byte) 'x');
+    Files.write(tokenFile, content);
+
+    assertThrows(IOException.class, () -> ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  @Test
+  void emptyTokenFileIsRejected() throws Exception {
+    Path tokenFile = tempDir.resolve("empty-token");
+    Files.write(tokenFile, new byte[0]);
+
+    assertThrows(IOException.class, () -> ConnectionAuthenticator.readToken(tokenFile));
+  }
+
+  private static void runAuthenticatedSession(ProtocolVersion version) throws Exception {
+    Duplex duplex = new Duplex();
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    Future<?> server =
+        executor.submit(
+            () -> {
+              try {
+                ConnectionAuthenticator.authenticateAgent(duplex.serverIn, duplex.serverOut, TOKEN);
+                try (WireProtocol protocol =
+                    createServerProtocol(version, duplex.serverIn, duplex.serverOut)) {
+                  Command command = protocol.read();
+                  assertTrue(command instanceof ListProbesCommand);
+                  ListProbesCommand response = (ListProbesCommand) command;
+                  response.setProbes(Collections.singletonList("probe-1"));
+                  protocol.write(response);
+                  protocol.flush();
+                }
+              } catch (Exception e) {
+                throw new RuntimeException(e);
+              }
+            });
+
+    try {
+      ConnectionAuthenticator.authenticateClient(duplex.clientIn, duplex.clientOut, TOKEN);
+      try (WireProtocol protocol =
+          createClientProtocol(version, duplex.clientIn, duplex.clientOut)) {
+        protocol.write(new ListProbesCommand());
+        protocol.flush();
+        Command response = protocol.read();
+        assertTrue(response instanceof ListProbesCommand);
+        assertEquals(
+            Collections.singletonList("probe-1"), ((ListProbesCommand) response).getProbes());
+      }
+      server.get(5, TimeUnit.SECONDS);
+    } catch (ExecutionException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof Exception) {
+        throw (Exception) cause;
+      }
+      throw e;
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  private static WireProtocol createServerProtocol(
+      ProtocolVersion version, InputStream input, OutputStream output) throws Exception {
+    if (version == ProtocolVersion.V1) {
+      return new JavaSerializationProtocol(input, output);
+    }
+    ProtocolNegotiator negotiator = new ProtocolNegotiator(ProtocolVersion.V2);
+    PushbackInputStream negotiationInput = ProtocolNegotiator.createNegotiationStream(input);
+    assertEquals(ProtocolVersion.V2, negotiator.negotiateAgent(negotiationInput, output));
+    return new BinaryWireProtocol(negotiationInput, output);
+  }
+
+  private static WireProtocol createClientProtocol(
+      ProtocolVersion version, InputStream input, OutputStream output) throws Exception {
+    if (version == ProtocolVersion.V1) {
+      return new JavaSerializationProtocol(input, output);
+    }
+    ProtocolNegotiator negotiator = new ProtocolNegotiator(ProtocolVersion.V2);
+    assertEquals(ProtocolVersion.V2, negotiator.negotiateClient(input, output, ProtocolVersion.V2));
+    return new BinaryWireProtocol(input, output);
+  }
+
+  private static final class Duplex {
+    final InputStream clientIn;
+    final OutputStream clientOut;
+    final InputStream serverIn;
+    final OutputStream serverOut;
+
+    Duplex() throws IOException {
+      PipedInputStream clientInput = new PipedInputStream(32 * 1024);
+      PipedInputStream serverInput = new PipedInputStream(32 * 1024);
+      clientOut = new PipedOutputStream(serverInput);
+      serverOut = new PipedOutputStream(clientInput);
+      clientIn = clientInput;
+      serverIn = serverInput;
+    }
+  }
+}
diff --git a/integration-tests/src/test/java/tests/PreparedModeAuthenticationFunctionalTest.java b/integration-tests/src/test/java/tests/PreparedModeAuthenticationFunctionalTest.java
new file mode 100644
index 000000000..aae18d47e
--- /dev/null
+++ b/integration-tests/src/test/java/tests/PreparedModeAuthenticationFunctionalTest.java
@@ -0,0 +1,266 @@
+/*
+ * Copyright (c) 2008, 2024, Jaroslav Bachorik <j.bachorik@btrace.io>.
+ * All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package tests;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.btrace.client.Client;
+import io.btrace.core.comm.Command;
+import io.btrace.core.comm.StatusCommand;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermissions;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+/** End-to-end coverage for prepared-agent discovery, authentication, and wire negotiation. */
+public class PreparedModeAuthenticationFunctionalTest extends RuntimeTest {
+  private static final byte[] TOKEN =
+      "prepared-mode-functional-secret".getBytes(StandardCharsets.UTF_8);
+
+  @TempDir Path tempDir;
+
+  @BeforeAll
+  public static void setup() {
+    classSetup();
+  }
+
+  @BeforeEach
+  @Override
+  public void reset() {
+    super.reset();
+    setProtocol(2, false, true);
+  }
+
+  @AfterEach
+  public void restoreProtocol() {
+    setProtocol(2, false, true);
+  }
+
+  @ParameterizedTest(name = "client v{0}, server v{1}, auto={2}, supplied token={4}")
+  @CsvSource({
+    "1, 1, false, true, true",
+    "2, 2, false, true, true",
+    "2, 2, false, true, false",
+    "2, 1, true, false, true"
+  })
+  @DisplayName("Prepared agent authenticates before V1/V2 negotiation and fallback")
+  public void preparedAgentAuthenticatesAndNegotiates(
+      int clientProtocol,
+      int serverProtocol,
+      boolean autoNegotiate,
+      boolean forceVersion,
+      boolean suppliedToken)
+      throws Exception {
+    Path tokenFile = suppliedToken ? createTokenFile("prepared.token", TOKEN) : null;
+    TestApp app = launchPreparedApp(tokenFile, serverProtocol);
+    try {
+      assertProbeRoundTrip(app.getPid(), clientProtocol, autoNegotiate, forceVersion);
+    } finally {
+      app.stop();
+    }
+
+    if (suppliedToken) {
+      assertTrue(Files.isRegularFile(tokenFile), "User-supplied token must survive agent shutdown");
+    }
+  }
+
+  @Test
+  @DisplayName("Prepared agent rejects an incorrect token and accepts a later valid client")
+  public void incorrectTokenFailsClosedWithoutStoppingServer() throws Exception {
+    Path tokenFile = createTokenFile("incorrect-token.token", TOKEN);
+    TestApp app = launchPreparedApp(tokenFile, 2);
+    try {
+      int pid = app.getPid();
+      Files.write(
+          tokenFile,
+          "incorrect-secret".getBytes(StandardCharsets.UTF_8),
+          StandardOpenOption.TRUNCATE_EXISTING,
+          StandardOpenOption.WRITE);
+
+      IOException failure = assertAuthenticationFailure(pid);
+      assertTrue(
+          failure.getMessage().contains("authentication rejected"),
+          "Server must reject the incorrect prepared-mode credential");
+
+      Files.write(tokenFile, TOKEN, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
+      assertProbeRoundTrip(pid, 2, false, true);
+    } finally {
+      app.stop();
+    }
+  }
+
+  @Test
+  @DisplayName("Prepared client fails closed when the discovered token file disappears")
+  public void missingDiscoveredTokenFailsClosedWithoutStoppingServer() throws Exception {
+    Path tokenFile = createTokenFile("missing-token.token", TOKEN);
+    TestApp app = launchPreparedApp(tokenFile, 2);
+    try {
+      int pid = app.getPid();
+      Files.delete(tokenFile);
+
+      Client client = createClient();
+      try {
+        assertThrows(
+            IOException.class,
+            () -> client.attach(String.valueOf(pid), null, getEventsClassPath()));
+      } finally {
+        client.close();
+      }
+
+      createTokenFile(tokenFile.getFileName().toString(), TOKEN);
+      assertProbeRoundTrip(pid, 2, false, true);
+    } finally {
+      app.stop();
+    }
+  }
+
+  private TestApp launchPreparedApp(Path tokenFile, int protocol) throws Exception {
+    String agentArguments = "port=0,noServer=false,telemetry=false";
+    if (tokenFile != null) {
+      agentArguments += ",authTokenFile=" + tokenFile.toAbsolutePath();
+    }
+    return launchTestAppWithAgent(
+        "resources.Main",
+        agentArguments,
+        "-Dbtrace.comm.protocol=" + protocol,
+        "-Dbtrace.comm.autoNegotiate=false",
+        "-Dbtrace.comm.forceVersion=true");
+  }
+
+  private IOException assertAuthenticationFailure(int pid) throws Exception {
+    Client client = createClient();
+    File traceFile = locateTrace("btrace/OnTimerArgTest.java");
+    assertNotNull(traceFile, "Test probe source is missing");
+    byte[] code = client.compile(traceFile.getAbsolutePath(), getEventsClassPath());
+    assertNotNull(code, "BTrace compilation failed");
+    try {
+      client.attach(String.valueOf(pid), null, getEventsClassPath());
+      return assertThrows(
+          IOException.class,
+          () ->
+              client.submit(
+                  "localhost",
+                  traceFile.getName(),
+                  code,
+                  new String[] {"timer=200"},
+                  command -> {}));
+    } finally {
+      client.close();
+    }
+  }
+
+  private void assertProbeRoundTrip(
+      int pid, int protocol, boolean autoNegotiate, boolean forceVersion) throws Exception {
+    setProtocol(protocol, autoNegotiate, forceVersion);
+    File traceFile = locateTrace("btrace/OnTimerArgTest.java");
+    assertNotNull(traceFile, "Test probe source is missing");
+    Client client = createClient();
+    ExecutorService executor = Executors.newSingleThreadExecutor();
+    CompletableFuture<Void> started = new CompletableFuture<>();
+    try {
+      client.attach(String.valueOf(pid), null, getEventsClassPath());
+      byte[] code = client.compile(traceFile.getAbsolutePath(), getEventsClassPath());
+      assertNotNull(code, "BTrace compilation failed");
+
+      Future<?> submission =
+          executor.submit(
+              () -> {
+                try {
+                  client.submit(
+                      "localhost",
+                      traceFile.getName(),
+                      code,
+                      new String[] {"timer=200"},
+                      command -> handleStatus(client, command, started));
+                } catch (Throwable failure) {
+                  started.completeExceptionally(failure);
+                  throw new RuntimeException(failure);
+                }
+              });
+
+      started.get(20, TimeUnit.SECONDS);
+      try {
+        submission.get(10, TimeUnit.SECONDS);
+      } catch (ExecutionException ignored) {
+        // sendDisconnect closes the command stream after the successful STATUS round trip.
+      }
+    } finally {
+      client.close();
+      executor.shutdownNow();
+    }
+  }
+
+  private static void handleStatus(Client client, Command command, CompletableFuture<Void> started)
+      throws IOException {
+    if (command.getType() != Command.STATUS) {
+      return;
+    }
+    StatusCommand status = (StatusCommand) command;
+    if (status.getFlag() != StatusCommand.STATUS_FLAG) {
+      return;
+    }
+    if (!status.isSuccess()) {
+      started.completeExceptionally(new IllegalStateException("Probe startup failed"));
+      return;
+    }
+    client.sendDisconnect();
+    started.complete(null);
+  }
+
+  private Client createClient() {
+    File traceFile = locateTrace("btrace/OnTimerArgTest.java");
+    assertNotNull(traceFile, "Test probe source is missing");
+    return createClientForTests(traceFile.getParentFile().getAbsolutePath());
+  }
+
+  private Path createTokenFile(String name, byte[] token) throws IOException {
+    Path path = tempDir.resolve(name);
+    try {
+      Files.createFile(
+          path, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
+    } catch (UnsupportedOperationException ignored) {
+      Files.createFile(path);
+    }
+    Files.write(path, token, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE);
+    return path;
+  }
+
+  private static void setProtocol(int protocol, boolean autoNegotiate, boolean forceVersion) {
+    System.setProperty("btrace.comm.protocol", String.valueOf(protocol));
+    System.setProperty("btrace.comm.autoNegotiate", String.valueOf(autoNegotiate));
+    System.setProperty("btrace.comm.forceVersion", String.valueOf(forceVersion));
+  }
+}
diff --git a/integration-tests/src/test/java/tests/RuntimeTest.java b/integration-tests/src/test/java/tests/RuntimeTest.java
index ba36f6929..b86857aec 100644
--- a/integration-tests/src/test/java/tests/RuntimeTest.java
+++ b/integration-tests/src/test/java/tests/RuntimeTest.java
@@ -892,6 +892,7 @@ public static final class TestApp {
     private int pid;
     private final CountDownLatch testAppLatch = new CountDownLatch(1);
     private final Process process;
+    private final StringBuilder startupStderr = new StringBuilder();
 
     public TestApp(Process process, boolean debug) {
       this.process = process;
@@ -934,7 +935,9 @@ public TestApp(Process process, boolean debug) {
                         || l.contains("XML libraries not available")) {
                       continue;
                     }
-                    testAppLatch.countDown();
+                    synchronized (startupStderr) {
+                      startupStderr.append(l).append('\n');
+                    }
                     if (debug) {
                       System.err.println("[traced app] " + l);
                     }
@@ -948,6 +951,21 @@ public TestApp(Process process, boolean debug) {
 
       outT.start();
       errT.start();
+
+      Thread exitT =
+          new Thread(
+              () -> {
+                try {
+                  process.waitFor();
+                } catch (InterruptedException e) {
+                  Thread.currentThread().interrupt();
+                } finally {
+                  testAppLatch.countDown();
+                }
+              },
+              "Target Process Waiter");
+      exitT.setDaemon(true);
+      exitT.start();
     }
 
     public void stop() throws InterruptedException {
@@ -960,12 +978,33 @@ public void stop() throws InterruptedException {
     }
 
     public int getPid() throws InterruptedException {
-      testAppLatch.await();
+      if (!testAppLatch.await(30, TimeUnit.SECONDS)) {
+        throw new IllegalStateException("Target process did not become ready within 30 seconds");
+      }
+      if (pid <= 0) {
+        String errors;
+        synchronized (startupStderr) {
+          errors = startupStderr.toString();
+        }
+        throw new IllegalStateException("Target process exited before becoming ready: " + errors);
+      }
       return pid;
     }
   }
 
   public TestApp launchTestApp(String testApp, String... cmdArgs) throws Exception {
+    return launchTestAppInternal(testApp, null, Collections.emptyList(), cmdArgs);
+  }
+
+  protected TestApp launchTestAppWithAgent(
+      String testApp, String agentArguments, String... jvmArguments) throws Exception {
+    return launchTestAppInternal(
+        testApp, agentArguments, Arrays.asList(jvmArguments), new String[0]);
+  }
+
+  private TestApp launchTestAppInternal(
+      String testApp, String agentArguments, List<String> additionalJvmArguments, String... cmdArgs)
+      throws Exception {
     if (forceDebug) {
       // force debug flags
       debugBTrace = true;
@@ -983,14 +1022,24 @@ public TestApp launchTestApp(String testApp, String... cmdArgs) throws Exception
     }
     args.add("-XX:+AllowRedefinitionToAddDeleteMethods");
     args.add("-XX:+IgnoreUnrecognizedVMOptions");
+    args.add("-Dbtrace.suppressJavaDeprecationWarning=true");
     // uncomment the following line to get extra JFR logs
     //    args.add("-Xlog:jfr*=trace");
     args.addAll(extraJvmArgs);
+    args.addAll(additionalJvmArguments);
     if (startJfr) {
       jfrFile = Files.createTempFile("btrace-", ".jfr").toString();
       args.add("-XX:StartFlightRecording=settings=default,dumponexit=true,filename=" + jfrFile);
     }
+    if (agentArguments != null) {
+      Path agentPath = locateAgent();
+      if (agentPath == null) {
+        throw new IllegalStateException("Missing btrace.jar or btrace-agent.jar");
+      }
+      args.add("-javaagent:" + agentPath + "=" + agentArguments);
+    }
     args.add(testApp);
+    args.addAll(Arrays.asList(cmdArgs));
 
     ProcessBuilder pb = new ProcessBuilder(args);
     pb.environment().remove("JAVA_TOOL_OPTIONS");