How to Use EndpointHooks in Iroh for Connection Filtering

Iroh's EndpointHooks API allows you to intercept and control connection attempts by implementing the EndpointHooks trait, providing before_connect for outbound filtering and after_handshake for inbound validation to accept or reject connections with custom QUIC error codes.

Iroh is a modern peer-to-peer networking library that exposes fine-grained control over QUIC connections through its Endpoint abstraction. The EndpointHooks in Iroh for connection filtering feature enables developers to implement security policies, rate limiting, and access control by inspecting connection metadata before and during the handshake phase. These hooks integrate directly into the connection lifecycle defined in iroh/src/endpoint.rs, allowing you to reject unwanted peers without establishing costly transport state.

Endpoint Hook Points and Lifecycle

Iroh provides two distinct intervention points in the connection establishment flow, defined in iroh/src/endpoint/hooks.rs:

Hook Point When It Executes Return Type Effect
before_connect Immediately after Endpoint::connect is called, before any packets are sent BeforeConnectOutcome (Accept or Reject) Returning Reject aborts the connection attempt locally with zero network traffic
after_handshake After the QUIC/TLS handshake completes for both incoming and outgoing connections AfterHandshakeOutcome (Accept or Reject { error_code, reason }) Returning Reject transmits a QUIC CONNECTION_CLOSE frame with the specified error code and reason string

The after_handshake hook is particularly powerful because it executes after the remote's endpoint ID (a 256-bit public key) and ALPN protocol are known, enabling cryptographic identity-based filtering.

Core Trait Definition

The hook interface is defined in iroh/src/endpoint/hooks.rs as the EndpointHooks trait:

pub trait EndpointHooks: std::fmt::Debug + Send + Sync {
    fn before_connect<'a>(
        &'a self,
        _remote_addr: &'a EndpointAddr,
        _alpn: &'a [u8],
    ) -> impl Future<Output = BeforeConnectOutcome> + Send + 'a {
        async { BeforeConnectOutcome::Accept }
    }

    fn after_handshake<'a>(
        &'a self,
        _conn: &'a Connection,
    ) -> impl Future<Output = AfterHandshakeOutcome> + Send + 'a {
        async { AfterHandshakeOutcome::Accept }
    }
}

Both methods provide default implementations that accept all connections, so you only need to override the specific hook point relevant to your use case.

How the Hook Chain Works

When you attach multiple hooks via Builder::hooks, the endpoint constructs an EndpointHooksList (an internal Vec<Box<dyn DynEndpointHooks>>) that executes hooks sequentially in registration order:

  1. Before Connect Phase: The endpoint invokes before_connect on each hook in order. If any hook returns Reject, the connection attempt aborts immediately and subsequent hooks are skipped.
  2. After Handshake Phase: Upon successful QUIC handshake, the endpoint invokes after_handshake on each hook. The first Reject causes the connection to close with the specified error code.

This chaining behavior allows you to compose multiple filters—place the most specific or cheapest checks first to minimize latency.

Implementing a Custom Connection Filter

To create a custom filter using EndpointHooks in Iroh, follow these implementation steps:

  1. Define a struct containing your filter state (whitelists, rate limiters, or configuration).
  2. Implement EndpointHooks for your struct, overriding before_connect for outbound filtering or after_handshake for inbound validation.
  3. Return rejection outcomes with appropriate QUIC error codes (as VarInt) when connections violate your policy.
  4. Attach the hook during endpoint construction using the builder pattern.

Example: Simple Inbound IP Filter

The repository includes a concrete example in iroh/examples/incoming-filter.rs that demonstrates rejecting connections based on source IP address. Here is the essential implementation pattern:

use iroh::endpoint::{Connection, EndpointHooks, AfterHandshakeOutcome};
use iroh_base::endpoint::EndpointAddr;
use iroh_quinn::VarInt;
use ipnet::Ipv4Net;
use std::net::IpAddr;

#[derive(Debug)]
struct IncomingFilter {
    allowed_subnet: Ipv4Net,
}

impl EndpointHooks for IncomingFilter {
    async fn after_handshake(&self, conn: &Connection) -> AfterHandshakeOutcome {
        let remote_ip = conn.remote_addr().ip();
        if self.allowed_subnet.contains(&remote_ip) {
            AfterHandshakeOutcome::Accept
        } else {
            // QUIC error 0x0b = "Connection Refused"
            AfterHandshakeOutcome::reject(VarInt::from_u32(0x0b), b"IP not allowed")
        }
    }
}

When deployed, this hook inspects every incoming connection after the handshake completes. Connections originating outside the configured subnet receive a QUIC CONNECTION_CLOSE frame with error code 0x0b and the reason string "IP not allowed".

Example: Outbound Whitelist by Endpoint ID

For outbound connections, use before_connect to filter by the remote's endpoint ID (public key) before any network packets leave the local machine:

use iroh::endpoint::{EndpointHooks, BeforeConnectOutcome, EndpointAddr};
use iroh_base::endpoint::EndpointId;
use std::collections::HashSet;

#[derive(Debug)]
struct OutboundWhitelist {
    allowed_ids: HashSet<EndpointId>,
}

impl EndpointHooks for OutboundWhitelist {
    async fn before_connect(
        &self,
        remote_addr: &EndpointAddr,
        _alpn: &[u8],
    ) -> BeforeConnectOutcome {
        if self.allowed_ids.contains(&remote_addr.id) {
            BeforeConnectOutcome::Accept
        } else {
            // Abort immediately without transmitting any packets
            BeforeConnectOutcome::Reject
        }
    }
}

This pattern is ideal for implementing strict trust boundaries where you only communicate with pre-approved peers, as the rejection occurs entirely locally with zero network overhead.

Additional Reference Implementations

The Iroh repository provides several complete examples demonstrating EndpointHooks for connection filtering:

Best Practices for Endpoint Hooks

When implementing connection filtering with EndpointHooks in Iroh, adhere to these guidelines from the source code in iroh/src/endpoint/hooks.rs and iroh/src/endpoint/connection.rs:

  • Avoid storing Endpoint references in hooks. This creates reference cycles that prevent the endpoint from shutting down properly.
  • Keep hook logic lightweight. Hooks execute on the connection task's executor; perform heavy work asynchronously via tokio::spawn or channels to avoid blocking the QUIC event loop.
  • Use after_handshake for identity validation. The remote endpoint ID is only available after the handshake completes, making this the correct hook for cryptographic authentication.
  • Use before_connect for early rejection. Filter by IP or endpoint ID before any packets are sent to minimize resource consumption and exposure.
  • Provide descriptive error codes. When rejecting in after_handshake, use standard QUIC error codes and clear reason strings to aid remote diagnostics.

Summary

  • EndpointHooks in Iroh provide before_connect and after_handshake interception points for fine-grained connection control, defined in iroh/src/endpoint/hooks.rs.
  • The EndpointHooks trait requires implementing async methods that return BeforeConnectOutcome or AfterHandshakeOutcome to accept or reject connections.
  • Hooks execute sequentially in an EndpointHooksList, with the first rejection terminating the connection attempt.
  • Inbound filtering should use after_handshake to access the remote endpoint ID and ALPN, as demonstrated in iroh/examples/incoming-filter.rs.
  • Outbound filtering should use before_connect to abort connections locally before any network traffic occurs.
  • Never store Endpoint instances inside hook structs to avoid memory leaks.

Frequently Asked Questions

How do I attach multiple EndpointHooks to a single Iroh endpoint?

You can chain multiple hooks using the builder pattern's hooks method. Each call appends the hook to the internal EndpointHooksList, and they execute in the order registered:

let endpoint = Endpoint::builder()
    .hooks(ip_filter)
    .hooks(auth_filter)
    .bind(local_addr)?
    .listen()
    .await?;

What is the difference between Reject in before_connect versus after_handshake?

A Reject from before_connect aborts the connection locally before any QUIC packets are transmitted, resulting in zero network overhead. A Reject from after_handshake occurs after the cryptographic handshake completes and sends a QUIC CONNECTION_CLOSE frame to the peer with a specified error code and reason string, allowing the remote side to log why the connection was refused.

Can I access the remote's public key (endpoint ID) in before_connect?

No. The remote endpoint ID is only established after the cryptographic handshake completes. In before_connect, you have access to the EndpointAddr (which may contain a provisional ID based on the address) and the ALPN, but cryptographic verification happens later. Use after_handshake to filter based on the authenticated endpoint ID.

Are EndpointHooks called for every connection or just incoming ones?

EndpointHooks are invoked for both incoming and outgoing connections. The before_connect hook only executes for outbound connections (initiated via Endpoint::connect), while after_handshake executes for all connections after the TLS/QUIC handshake completes, regardless of which side initiated the connection.

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 →