Skip to main content
The three topologies in Topologies put the inspecting endpoint on the target. This one puts it in the middle. The bastion accepts the client’s jump channel, and instead of dialling the target and piping bytes, it runs an SSH server inside that channel and opens its own connection to a stock sshd. It decrypts both hops, so guardrails, the analyzer, masking and the audit trail all apply — to a host with nothing installed on it. One block turns it on. Add ssh.relay to a listener and the destinations it names get terminated. Delete it and you have the ordinary blind bastion of Mode 2.
This inverts the security model of the other three modes. The bastion holds the plaintext of every session for every host behind it, plus credentials that reach all of them. A blind bastion holds neither. Modes 1–3 degrade one host at a time; this one loses the fleet.

When to use it

The rule of thumb: if you can run a sidecar on the target, do that instead. Modes 1–3 keep the plaintext and the credentials on the host they belong to. This mode moves both to the bastion, and that is a real cost you pay on every host behind it. Reach for it when that is not an option: Use a different mode when: Both can be true at once. A single listener terminates the destinations in relay.targets and carries everything else blind, so you can inspect the hosts that matter and leave the rest alone — on one bastion, with one client config.

Connection flow

The client still makes two hops, the same two as every other mode. What changes is where hop 2 lands. The relay match is the only new decision. Everything around it behaves exactly as it does on a blind bastion. There’s no SNI in SSH. Nothing in the transport says which host the user wanted — so that match has exactly one thing to work with. ProxyJump opens a direct-tcpip channel, and the hostname you typed travels in that channel-open request. It’s the only place in the protocol where an unmodified client tells an intermediary the host it wants. The certificate is verified twice on purpose. Hop 1 authorises opening a forward. Hop 2 authorises a session on whatever answered. They’re separate handshakes with independent trust decisions. Merging them would mean the inner server trusting a claim the outer one made about a connection it no longer controls. Reachability is checked before termination. destinations_allowed runs first, against the resolved address. A host in relay.targets that destinations_allowed doesn’t cover is unreachable — so the target map can’t become a second, quieter way to widen access. Write the host in both places.
The upstream is the bastion’s own connection, not a hop the client makes. That’s why the target’s sshd authenticates the bastion’s credential — unless the target sets agent_identity.

Example configuration

config.yaml

Listener keys on a relay lane

Try to get a shell on the bastion itself and you get:
jump@ is a name, not an account. The bastion checks it against the certificate’s principals and never looks it up in a user database, because no session runs there. The account on the target comes from the client’s User, or a per-target login.

ssh.relay reference

targets.<key>

Unknown keys are rejected at load, inside the relay block as well as outside it. A typo on the block that decides which hosts get inspected won’t be silently dropped.

Targets

A target key is a name, a glob, an address, or a network — each with an optional :port.

Precedence

exact name → glob → literal address → longest prefix. Within a kind: longer glob first, more specific prefix first, a key with a port before the same key without one, then key text. It’s total and fixed, so map iteration never decides which rule applied — which would otherwise vary per connection. Name keys match before DNS resolution and address keys after. That’s what stops a name key from widening reach: destinations_allowed still decides that, on the resolved address.

Unmatched destinations

That’s what the other three modes do, and it still works — it’s now an explicit choice instead of the only option. A blind forward produces a forward_open / forward_close pair with the destination, the resolved address, the duration and byte counts. Nothing else: the bastion piped bytes it couldn’t read, and the far end verified the client’s own certificate.
any is not a valid target key and fails at load. destinations_allowed decides where a forward may be carried; this map decides which destinations get terminated. Terminating everything reachable would turn every forward into a session the bastion decrypts.

Upstream credentials

Three sources. A target picks exactly one — they are not a fallback chain.

private_key

The shared key — the floor every target has. It is read and checked at load, like host_key and trusted_ca:
  • an unreadable path stops startup;
  • a key readable by group or other is refused, same as ssh(1);
  • a file that’s actually a public key is called out by name. It’s an easy mistake, since the .pub is the file you copy into the target’s authorized_keys.
Add from="<bastion addresses>" to the target’s authorized_keys entry for every key you install this way. It turns a leaked key from “fleet access from anywhere” into “fleet access, but only from the bastion”. hoop can’t enforce this — it lives on the target.If you want local_forward, the same entry must not carry restrict or no-port-forwarding.

identities

A directory whose filenames are certificate subjects, holding one private key each. It adds an overlay; it doesn’t replace anything.
Keys are read from disk per session, so enrolling or removing someone takes effect on their next connection — no restart. At load, hoop only checks that the path is a readable directory. The subject becomes a filename, so it’s validated before it becomes a path. Empty, contains / \ or NUL, equals . or .., or starts with a dot — all refused. Anything that passes is a plain filename inside the directory. A file that exists but can’t be used refuses. Someone deliberately enrolled that person; quietly serving them as the shared account would hide a real problem.
The overlay is lane-wide. One key per subject serves every target the lane terminates. So an enrolled subject needs that public key in authorized_keys on every host they can reach, or they’ll be refused there while working everywhere else. This trips people up more than anything else on this page.Write access to this directory is equivalent to granting SSH to every target the overlay reaches. Mount it read-only from wherever enrolment actually lives.
Falling back to private_key is logged every time, as upstream_credential_fallback. Overlays fail quietly — a subject format changes at the IdP, a filename stops matching — and everything keeps working on the shared key while silently naming nobody. Once your people are enrolled, drop private_key from that target. Keeping both is a migration state, not a destination. A target with no private_key under identities has no fallback: an unenrolled subject is refused rather than admitted as someone shared.

agent_identity

The client’s forwarded agent signs the upstream handshake, so the user’s own certificate reaches the target and its sshd verifies the real person against TrustedUserCAKeys. Nothing is stored on the bastion and nothing hoop-specific is installed on the target.
The agent shows up at hop 2, not hop 1. ProxyJump opens no session channel on the bastion, and auth-agent-req@openssh.com is a session request. So the upstream dial moves to the first session channel — the credential doesn’t exist before then — and an upstream failure surfaces at first use rather than at connect. The bastion consumes the agent; it never forwards it. The channel is opened to sign and closed with the session. Each signature is bound to the upstream session id and the target’s host key, so nothing it obtains is replayable elsewhere. What the bastion holds is the ability to ask again, for the life of the session.
agent_identity is for people, not automation. A client with no agent is refused, never downgraded — the alternative is silently falling back to an account that names nobody.Two smaller catches: a certificate carrying source-address is checked against the bastion’s address, so certs pinned to user networks stop working through a bastion at all. And if the agent goes away mid-session, the next dial fails with no way to re-authenticate.

Credential conflicts


Host key verification

known_hosts is required. The bastion terminates hop 2, so it presents its own key where the target’s would be. The client verified the bastion and nothing else — the bastion’s check is all that’s left of host identity in the path. The key is verified under the name the client asked for, not the address dialled. That’s how you write known_hosts entries, and what ssh(1) does.

host_key_check

accept_new still refuses a changed key — that’s the point of it. You give up detection at first contact and keep it for every session after. A rebuilt host still fails. It also makes known_hosts a state file. It’s the only path in this schema the sidecar writes.
accept_new learns a key by renaming a new file over the old one, so the path has to be replaceable, not just writable. A bind-mounted file rejects that rename. Mount the directory instead.The load check performs the real rename with the file’s own contents, so you find out at startup rather than on the first unknown host.
Nobody reviews what it learns before it’s used. A client prompt shows a fingerprint to a person who could check it; the bastion has nobody to ask. So the host and fingerprint go to the trail as host_key_first_contact — reviewable afterwards, not before.

host_key_check: off

off means nothing in the path authenticates the host. HostKeyAlias already moved host identity from the client to the bastion, so this decides whether the check happens at all — not which side does it. You get a log.Warn at load naming the listener and target, plus a host_key_unverified audit event on every session it covers.

Target capabilities

Tri-state, with the same three readings as the listener’s own list: Writing the key with no value is an error, because capabilities_allowed: becomes null and null is indistinguishable from an absent key by the time the field is set. Omit it, or write []. A target can only narrow. Naming a capability the listener doesn’t admit fails at load: a target NARROWS the listener’s list and cannot widen it. local_forward is the one exception — it’s never on a listener’s list at all, since forwarding at the listener is destinations_allowed’s job.

Delivered capabilities

Inheritance silently drops what can’t be carried. A listener with sftp in capabilities_allowed is fine — that’s correct for the lane. Targets inheriting the list just don’t get it. Naming sftp explicitly on a target is still a load error. No guardrails on an interactive shell — the reasoning is the same as on an end-hop. Terminating in the middle doesn’t turn a keystroke stream into statements. If you need every action to be a rule-readable statement, set capabilities_allowed: [exec, env] on your targets. See Interactive shells.

File transfer

On an end-hop the sidecar is the file-transfer server and sees decoded operations. Against a remote sftp-server it’s an opaque subsystem stream, and keeping path gating and download masking would mean decoding the protocol in both directions. Piping it un-decoded would leave file transfer as the one un-inspected path on a listener whose whole job is inspection. This affects sftp, scp on OpenSSH 9 defaults, and rsync. See File Transfer for workarounds.

local_forward

ssh -L works inside a terminated session, and the TARGET makes the TCP connection. The bastion opens a direct-tcpip channel on its own upstream connection and splices the two together. 127.0.0.1 in forwards_allowed is the target’s loopback. That’s the whole reason the target dials it. If the bastion dialled, localhost would mean the bastion — a security bug, not a shortcut.

forwards_allowed

The destination is checked as the client typed it, not as a resolved address. The target resolves it, on its own host and in its own network, so resolving at the bastion would answer a different question. That’s why a network entry can’t cover a hostname: guessing with a local resolver means checking one answer and connecting to another. any is rejected. The allowlist is what keeps uninspected traffic a deliberate exception rather than a hole, so it has to name where.

Required pairing

This is intentionally stricter than destinations_allowed, where absent means a sensible “no forwards”. Here both facts are known at load, and admitting the capability while bounding it nowhere is a contradiction.

Inspection limits

Guardrails and masking work on statements and terminal output. An arbitrary TCP stream has neither. You get metadata only — target, destination, open and close times, byte counts, and every refusal with its reason. Exfiltration through an allowed destination shows up as volume and timing, never content. Keep these lists narrow.
The certificate’s grant doesn’t narrow this. permit-port-forwarding is already required to use the bastion as a jump, so everyone with a working certificate has it. Anyone who can reach a target gets that target’s whole allowlist. There are no per-user or per-group forwarding grants.
ssh -R is refused. It exposes the user’s machine to the target rather than the reverse, and needs a listener on the target plus reverse-channel plumbing that doesn’t exist here.

Target-side refusals

AllowTcpForwarding defaults to yes and PermitOpen to any, so a stock sshd carries a forward unchanged. A host hardened with AllowTcpForwarding no refuses, and so does a credential whose authorized_keys entry carries restrict or no-port-forwarding. That refusal is the target’s to make, and hoop surfaces it rather than retrying:
The client can’t see the target, so the bastion is the only place to diagnose this. The full upstream error is in the trail and the log.

login

By default the session logs in as the name the client asked for, which the certificate’s principals vouched for. login overrides that for a lane or a single target:
Precedence: target → lane → the client’s own User.

Client configuration

Two stanzas, and neither names a host. That’s what makes an evaluation across twelve hosts a config change on one box instead of twelve installs.

HostKeyAlias

The bastion terminates hop 2, so it presents its key where the target’s would be. Without HostKeyAlias every connection is a host-key mismatch. Pointing every target’s lookup at one alias means one known_hosts entry covers the fleet. The cost is real: a user who bypasses the bastion gets a first-contact prompt instead of a mismatch warning, so network policy has to carry what host-key checking no longer does.
List blind destinations before the HostKeyAlias stanza. ssh takes the first value it finds for each keyword, so a blind destination that also matched the stanza below would inherit HostKeyAlias hoop-bastion and store its own host key under the bastion’s name. You’ll see REMOTE HOST IDENTIFICATION HAS CHANGED on a setup where nothing changed.HostKeyAlias belongs only on destinations the bastion terminates — those are the ones where the bastion’s key answers in the target’s place.

LogLevel

A refused channel — a capability the target doesn’t admit, a forward outside the allowlist, a credential that didn’t resolve — is reported by the client at INFO and swallowed at ERROR. On a lane whose whole job is refusing things, that makes every denial look like a hang.

Client-facing messages

Refusals are written for two audiences, and only one crosses the wire. The client gets a short line telling them whether it’s theirs to fix. The log and the audit trail get everything — target key, subject, path, upstream error, and which config key decided. The destination is the client’s own words, so it travels back and makes the refusal actionable. Server paths, internal addresses, upstream accounts, host-key fingerprints and whether a given subject is enrolled do not — that last one would turn every refusal into an enrolment oracle for anyone holding a valid certificate.

Audit trail

Both hops present the same certificate, so hop 2’s connection_open carries hop and target. Without them the two events would be identical and you couldn’t tell the jump from the session that reached a host.
Statement records are the usual ones — exec_line and env_set, same verdicts, same masking — because it’s the same enforcement chain. A blocked command is refused before anything is written to the upstream channel, so the target’s sshd never learns it was attempted. That’s more than an end-hop gives you. No session content is recorded, here as everywhere else on an ssh lane.

Validation

Targets are listed in precedence order, which is why the glob comes last even though it is written first in the config. Every path is read and checked before anything binds:

Reload

The whole relay block is listener topology, so changing it needs a restart. Credentials, a target’s ceiling, a host-key setting and the target map all move the baseline rather than a rule. identities is the deliberate exception. Its keys are read per session, so dropping a file grants access immediately and deleting one revokes it just as fast. That convenience is exactly why the directory needs the read-only mount described above.

Trade-offs

Easier. Hosts that can’t run a sidecar get inspected, and per-target install cost drops to zero — for an evaluation, that’s the whole cost. One ingress address holds policy for a fleet. Blocked commands never reach the target. Harder, and this is the trade. The bastion holds the plaintext of every session for every host behind it, plus credentials that reach most of them. Two key exchanges per connection on one process, and masking latency now includes a network round trip. The provisioning bill is real. One identity key per subject on every host they can reach, removed when they leave. For N people and M hosts that’s a table you maintain by hand, with no tooling here and no drift detection. That’s the bill an SSH CA exists to remove — which is why identities is a bridge, and agent_identity is the better answer wherever the client has an agent.

Known gaps

  • No file transfer. sftp, scp and rsync don’t work through a terminated session.
  • No remote forwarding.
  • No guardrails on an interactive shell, same as on an end-hop.
  • No per-user or per-group forwarding grants. Anyone who can reach a target gets that target’s whole forwards_allowed.
  • No host CA. HostKeyAlias is today’s answer, and it collapses per-target host identity into one key.
  • No per-target rule sets. guardrails, mask and the analyzer apply to every target.

Next

Topologies

The other three modes, where the endpoint sits on the target and the bastion is blind.

Configuration

The listener’s own keys: capabilities_allowed, destinations_allowed and identity.

Certificates and Identity

permit-port-forwarding, principals, and what source-address means through a bastion.

File Transfer

Why sftp, scp and rsync are refused over a terminated session.