Securing SSH with Cloudflare Zero Trust

Background

To access a server via SSH, you typically need to expose port 22 to the internet. However, an open SSH port is a constant target for attacks.

With Cloudflare Zero Trust, you can build an environment where only authenticated users can connect via SSH, while keeping the SSH port completely closed.

What Is Zero Trust?

Traditional security models relied on the assumption that “anything inside the corporate network is trusted.” Zero Trust discards this assumption and instead verifies every access request.

[Traditional]
Internet -> Firewall -> Internal network (trusted)
  * Once inside, free access to everything

[Zero Trust]
Internet -> Cloudflare (authentication & authorization) -> Server
  * Every access request is verified
  * No connection without authentication

How It Works

For SSH, Cloudflare Tunnel and the Access feature work together.

Developer's PC
  +-- ssh command
      +-- cloudflared (ProxyCommand)
          +-- Cloudflare (Access authentication)
              +-- Tunnel
                  +-- Server's SSH daemon
  1. When an SSH command is executed, cloudflared acts as a proxy
  2. Cloudflare Access requests authentication (email verification via browser)
  3. After successful authentication, it connects to the server’s SSH through the Tunnel
  4. The server’s SSH port remains closed throughout

Prerequisites

Steps

1. Add SSH to the Tunnel Ingress

Add SSH routing via the Cloudflare API:

curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/cfd_tunnel/<TUNNEL_ID>/configurations" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "config": {
      "ingress": [
        {"hostname": "ssh-cf.example.jp", "service": "ssh://host.docker.internal:22"},
        {"service": "http_status:404"}
      ]
    }
  }'

If cloudflared is running as a Docker container, use host.docker.internal to reach the host’s SSH daemon. You’ll need to add extra_hosts to your docker-compose.yml:

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    extra_hosts:
      - host.docker.internal:host-gateway

2. Add a DNS Record

cloudflared tunnel route dns my-tunnel ssh-cf.example.jp

3. Create an Access Application

Create an Access application for SSH via the Cloudflare API:

curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/apps" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "My Server SSH",
    "domain": "ssh-cf.example.jp",
    "type": "ssh",
    "session_duration": "24h"
  }'

The type: "ssh" setting is important – it tells Cloudflare to act as an SSH proxy.

4. Configure an Access Policy

Control who is allowed to SSH. Here is an example that restricts access by email address:

curl -X POST "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/apps/<APP_ID>/policies" \
  -H "X-Auth-Email: <EMAIL>" \
  -H "X-Auth-Key: <API_KEY>" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "Allow admin",
    "decision": "allow",
    "include": [
      {"email": {"email": "admin@example.com"}}
    ]
  }'

You can also restrict access based on other criteria:

  • Email domain (e.g., everyone with @example.ac.jp)
  • IP address range
  • Country
  • IdP (Google Workspace, GitHub, etc.) groups

5. Configure SSH on Your Local Machine

Add the following to ~/.ssh/config:

Host my-server-cf
  HostName ssh-cf.example.jp
  User myuser
  IdentityFile ~/.ssh/my_key
  ProxyCommand cloudflared access ssh --hostname %h

6. Connect

ssh my-server-cf

The first time you connect, a browser window will open and prompt you to verify your email address. After authentication, the token is cached locally and you can connect without re-authenticating for the duration of session_duration (24 hours).

7. Close the SSH Port

Once you have confirmed that you can connect via Zero Trust SSH, close port 22 on the server’s firewall or security group.

Comparison with Traditional SSH

ItemStandard SSHZero Trust SSH
Port exposurePort 22 requiredNot required
AuthenticationKey-based onlyKey-based + Cloudflare Access
Brute forceAt riskNo attack surface
Access controlIP restriction at bestFlexible: email, IdP, country, etc.
Audit logsServer-side onlyAlso recorded on Cloudflare
VPNSometimes requiredNot required

Troubleshooting

Empty app domain Error

This is caused by a stale token cache. Clear it with:

rm -f ~/.cloudflared/*.lock ~/.cloudflared/*-token*

Then run the ssh command again and re-authenticate in the browser.

Connection timed out during banner exchange

The cloudflared container may not be able to reach the host’s SSH daemon.

  • Verify that extra_hosts is configured
  • Add --protocol http2 to your docker-compose.yml (QUIC connections may fail in environments that restrict UDP)
  • Test connectivity from the container to the host: docker run --rm --add-host host.docker.internal:host-gateway alpine sh -c "nc -zv host.docker.internal 22" (alpine includes nc by default)

QUIC Error

Failed to dial a quic connection

This occurs when the server’s network restricts UDP. Add --protocol http2 to the cloudflared startup command:

command: tunnel --protocol http2 run

Conclusion

By using Cloudflare Zero Trust SSH, you can securely connect to a server with the SSH port completely closed. Since Cloudflare Access authentication is layered on top of key-based authentication, you achieve defense in depth. Even if your SSH keys are compromised, no one can connect without also passing Cloudflare Access authentication.