Configuring endpoint connection hooks for authentication in iroh

Use the EndpointHooks trait in iroh/src/endpoint/hooks.rs to intercept connections before and after the QUIC handshake, enabling custom authentication flows like token-based pre-authorization without modifying underlying protocols.

The iroh networking library from n0-computer provides a flexible hook system that lets you inject custom logic into the connection lifecycle. By implementing the EndpointHooks trait, you can configure endpoint connection hooks for authentication in iroh to validate remote identities, enforce rate limits, or implement token-based authorization before application data flows.

The EndpointHooks trait architecture

The hook system is defined in iroh/src/endpoint/hooks.rs and centers on a single trait that enables asynchronous interception of connection events.

pub trait EndpointHooks: Send + Sync + 'static {
    async fn before_connect<'a>(
        &'a self,
        remote_addr: &'a EndpointAddr,
        alpn: &'a [u8],
    ) -> BeforeConnectOutcome;

    async fn after_handshake<'a>(
        &'a self,
        conn: &'a Connection,
    ) -> AfterHandshakeOutcome;
}
  • before_connect runs before the QUIC handshake completes. It receives the remote address and ALPN protocol identifier, returning either Accept to proceed or Reject to abort the connection attempt.
  • after_handshake runs after the handshake succeeds, when the remote's identity is verified and the ALPN is negotiated. This is the appropriate place to check authentication state against the specific connection.

Hooks are stored in an EndpointHooksList inside the endpoint builder and execute in the order they were added via Builder::hooks.

Implementing outgoing authentication hooks

For clients that must authenticate before connecting to protected services, implement an outgoing hook that performs pre-flight token validation.

File: iroh/examples/auth-hook.rs (module auth::OutgoingAuthHook)

The outgoing hook uses a channel-based architecture to communicate with a background task:

pub struct OutgoingAuthHook {
    tx: mpsc::Sender<(EndpointId, oneshot::Sender<AuthResult>)>,
}

In before_connect, the hook checks if the connection is an auth request itself (avoiding recursive authentication). For normal connections, it calls authenticate(remote_id).await:

async fn before_connect<'a>(
    &'a self,
    remote_addr: &'a EndpointAddr,
    alpn: &'a [u8],
) -> BeforeConnectOutcome {
    // Skip auth protocol connections to avoid recursion
    if alpn == AUTH_ALPN {
        return BeforeConnectOutcome::Accept;
    }
    
    // Attempt authentication via background task
    match self.authenticate(remote_addr.endpoint_id).await {
        Ok(()) => BeforeConnectOutcome::Accept,
        Err(_) => BeforeConnectOutcome::Reject,
    }
}

The background OutgoingAuthTask manages a set of allowed remote IDs and pending requests. When authentication is required, it spawns a dedicated connection using the auth ALPN (b"iroh-example/auth/0"), sends the secret token over a unidirectional stream, and interprets the close code: CLOSE_ACCEPTED (1) indicates success, while CLOSE_DENIED (403) indicates failure.

Implementing incoming authentication hooks

For servers that must verify clients have pre-authenticated, implement the incoming hook to gate access based on a shared allow-list.

File: iroh/examples/auth-hook.rs (module auth::IncomingAuthHook)

The IncomingAuthHook implements after_handshake to check if the remote ID exists in the allowed_remotes set:

async fn after_handshake<'a>(
    &'a self,
    conn: &'a Connection,
) -> AfterHandshakeOutcome {
    let remote_id = conn.endpoint_id();
    
    // Allow the auth protocol itself
    if conn.alpn() == Some(AUTH_ALPN) {
        return AfterHandshakeOutcome::Accept;
    }
    
    // Check if pre-authenticated
    if self.allowed_remotes.lock().unwrap().contains(&remote_id) {
        AfterHandshakeOutcome::Accept
    } else {
        AfterHandshakeOutcome::Close(403u32.into(), "not authenticated")
    }
}

The AuthProtocol handler runs on the server side to process authentication requests. It reads the token from the connection, validates it against the expected secret, and on success inserts the remote ID into the shared allowed_remotes set:

async fn accept(&self, conn: quinn::Incoming) -> anyhow::Result<()> {
    let conn = conn.await?;
    let (mut send, mut recv) = conn.accept_uni().await?;
    let token = recv.read_to_end(1024).await?;
    
    if token == self.expected_token {
        self.allowed_remotes.lock().unwrap().insert(conn.endpoint_id());
        conn.close(1u32.into(), b"accepted"); // CLOSE_ACCEPTED
    } else {
        conn.close(403u32.into(), b"denied"); // CLOSE_DENIED
    }
    Ok(())
}

Wiring hooks to your endpoint

To configure endpoint connection hooks for authentication in iroh, install the hooks during endpoint construction and wire the auth protocol into a router.

Server-side setup:

use iroh::{Endpoint, protocol::Router, endpoint::presets};

let secret_token = b"super-secret".to_vec();
let (auth_hook, auth_protocol) = auth::incoming(secret_token);

let endpoint = Endpoint::builder(presets::N0)
    .hooks(auth_hook)  // Install the incoming hook
    .bind()
    .await?;

let router = Router::builder(endpoint)
    .accept(auth::ALPN, auth_protocol)  // Register auth protocol
    .accept(b"my-app/0", MyAppHandler)
    .spawn();

Client-side setup:

let token = b"super-secret".to_vec();
let (auth_hook, auth_task) = auth::outgoing(token);

let endpoint = Endpoint::builder(presets::N0)
    .hooks(auth_hook)  // Install the outgoing hook
    .bind()
    .await?;

// Spawn the background authentication task
let _guard = auth_task.spawn(endpoint.clone());

// Subsequent connections automatically authenticate
let conn = endpoint.connect(remote_addr, b"my-app/0").await?;

Running the complete example

The repository includes a fully functional demonstration that exercises both sides of the authentication flow.

cargo run --example auth-hook

The example executes three scenarios sequentially:

  1. Connection without authentication — rejected with "not authenticated"
  2. Connection with invalid token — rejected with "authentication denied"
  3. Connection with valid token — succeeds and exchanges data

Review the complete implementation in iroh/examples/auth-hook.rs to see how the OutgoingAuthTask coordinates asynchronous token exchange while the IncomingAuthHook maintains the allow-list state.

Summary

  • Endpoint hooks in iroh/src/endpoint/hooks.rs provide before_connect for pre-handshake filtering and after_handshake for post-handshake authorization.
  • Outgoing hooks pair with background tasks to perform asynchronous authentication via dedicated protocol connections before allowing application traffic.
  • Incoming hooks check shared state (such as allowed_remotes) populated by a protocol handler that validates tokens and tracks authenticated peers.
  • Hooks are installed via Builder::hooks and execute sequentially; multiple hooks can be stacked for layered policies.
  • The auth-hook example demonstrates token-based pre-authentication using close codes (CLOSE_ACCEPTED vs CLOSE_DENIED) to signal success or failure.

Frequently Asked Questions

How do I prevent authentication from blocking the endpoint builder?

The outgoing hook uses a non-blocking channel (mpsc::Sender) to communicate with a background task (OutgoingAuthTask), allowing the before_connect hook to await authentication without blocking the endpoint builder or other connections. The hook itself remains lightweight while the background task handles the actual network I/O for token exchange.

Can I use multiple endpoint hooks simultaneously?

Yes. The Endpoint builder stores hooks in an EndpointHooksList and invokes them in registration order. If any hook returns Reject or Close, the connection attempt aborts immediately. This allows you to combine authentication hooks with rate-limiting or logging hooks by simply calling .hooks(hook1).hooks(hook2) during endpoint construction.

What is the difference between rejecting in before_connect versus after_handshake?

before_connect runs before the QUIC handshake completes, allowing you to reject connections based on IP address or ALPN without incurring the cost of cryptographic verification. after_handshake runs after the handshake succeeds, providing access to the authenticated remote identity (EndpointId), making it the correct place to verify pre-authentication status or implement identity-based access control.

How does the auth protocol avoid infinite recursion?

The outgoing hook checks if alpn == AUTH_ALPN and returns BeforeConnectOutcome::Accept immediately for auth protocol connections. This prevents the hook from attempting to authenticate the authentication connection itself, which would create a recursive loop. The incoming hook similarly exempts the auth ALPN from its allow-list check.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →