---
title: Observing and rejecting connections
description: Endpoint hooks run before an outgoing dial and after every handshake — the supported way to add authentication or an allow list.
icon: shield-check
---

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](https://docs.iroh.computer/connecting/endpoint-hooks);
this page covers the Go API and the behavior verified against it.

## The interface

```go
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:

- **`BeforeConnect`** — any error you return is returned verbatim from
  `Connect`. Nothing wraps it. Return `iroh.ErrConnectRejected` yourself if you
  want callers to match with `errors.Is`; it is a convention sentinel the
  endpoint never produces on its own.
- **`AfterHandshake`** — returning `iroh.RejectHandshake(code, reason)` closes
  the connection with that QUIC application close code and reason, and the
  local caller gets an error wrapping `iroh.ErrHandshakeRejected`. Any *other*
  error also closes the connection, but with code 0 and reason
  `"rejected by hook"`, and is returned unwrapped.

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

## An allow list

```go
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:

```go
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:

```text
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:

```go
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

- [Security and privacy](security-and-privacy) — what hooks do and do not
  protect against.
- [Endpoints and connections](endpoints-and-connections) — the rest of the
  connection API.
- [Troubleshooting](troubleshooting) — the sentinel errors in context.
