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.
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 aforward_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.
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
.pubis the file you copy into the target’sauthorized_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.
/ \ 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.
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.
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.
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.
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
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:
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:
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.
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’sconnection_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
Reload
The wholerelay 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 whyidentities is a bridge, and agent_identity is the better answer wherever the client has an agent.
Known gaps
- No file transfer.
sftp,scpandrsyncdon’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.
HostKeyAliasis today’s answer, and it collapses per-target host identity into one key. - No per-target rule sets.
guardrails,maskand 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.