Table of Contents

Observing and rejecting connections

Endpoint hooks run before an outgoing dial and after every handshake — the supported way to add authentication or an allow list.

Endpoint hooks intercept connection establishment. They are how you add authentication, an allow list, or connection logging without any cooperation from the protocols running inside those connections.

Upstream documents the same feature for Rust in Observing & Rejecting Connections; this page covers the Go API and the behavior verified against it.

The interface

type EndpointHooks interface {
	BeforeConnect(ctx context.Context, addr netaddr.EndpointAddr, alpn string) error
	AfterHandshake(ctx context.Context, conn *Conn) error
}

Register one or more with iroh.WithHooks at Bind time. They run in registration order, and the first error short-circuits the rest.

The two hook points are:

Hook When Direction
BeforeConnect before a dial sends any packet outbound only
AfterHandshake after the QUIC/TLS handshake, before application data inbound and outbound

By AfterHandshake the peer is authenticated, so conn.RemoteID() and conn.ALPN() are trustworthy. That is what makes an allow list possible.

Hooks observe and reject. They must not use the connection — opening streams from a hook would race the protocol handler that is about to own it.

Rejecting

The two hooks reject differently, and the difference matters:

So use RejectHandshake when you want the peer to learn why, and match with errors.Is(err, iroh.ErrHandshakeRejected) locally.

An allow list

type allowList struct{ allowed map[key.EndpointID]bool }

func (a allowList) BeforeConnect(ctx context.Context, addr netaddr.EndpointAddr, alpn string) error {
	return nil
}

func (a allowList) AfterHandshake(ctx context.Context, conn *iroh.Conn) error {
	if !a.allowed[conn.RemoteID()] {
		return iroh.RejectHandshake(1, "not on the allow list")
	}
	return nil
}

Install it on the accepting endpoint:

server, err := iroh.Bind(ctx,
	iroh.WithALPNs(alpn),
	iroh.WithHooks(allowList{allowed: map[key.EndpointID]bool{}}),
)

With an empty allow list, the server’s Accept returns:

iroh: handshake rejected by hook: reject handshake: code 1: not on the allow list

and errors.Is(err, iroh.ErrHandshakeRejected) is true.

The caveat worth knowing

A server-side AfterHandshake rejection does not make the client’s Connect fail. Running the program above, the client’s Connect returns a usable *iroh.Conn — its handshake completed — while the server’s Accept returns the rejection. The client only learns of the rejection when it uses the connection and sees it closed with your code and reason.

If your protocol needs the dialer to fail fast, the rejection has to be part of the protocol, not only the hook. Hooks are an admission-control mechanism for the accepting side; they are not a synchronous handshake-level “no” that the dialer observes immediately.

Earlier still: the router’s incoming filter

Hooks run after the handshake. That is too late to be a defense against an unauthenticated flood, because you have already paid for a handshake. For that, Router has a separate mechanism:

router, err := iroh.NewRouter(ep, handlers, &iroh.RouterConfig{
	IncomingFilter: func(in *iroh.Incoming) iroh.IncomingFilterOutcome {
		return iroh.FilterRetry
	},
})

The four outcomes are not equivalent, and the ordering is the point:

Outcome Effect
FilterAccept accept and dispatch to the handler for the ALPN
FilterRetry emit a real QUIC Retry packet, forcing address validation
FilterReject refuse the connection
FilterIgnore close it without dispatching

FilterRetry is evaluated at QUIC Initial admission time, before ALPN negotiation and before a connection is constructed, so it produces a genuine Retry packet — the standard QUIC defense against address-spoofed flooding. The other outcomes are evaluated in the accept loop once an early connection exists. So FilterRetry is a cheap-per-packet defense; hooks are an expensive, fully-informed one. Use them at different layers.

Graceful shutdown

Related, and also easy to miss: a protocol handler that implements iroh.ShutdownHandler gets its Shutdown(ctx) called once when the router shuts down, before the endpoint closes, which is your chance to close connections cleanly rather than having them dropped.

Next steps

Last updated: 2026-08-20