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
- When an SSH command is executed,
cloudflaredacts as a proxy - Cloudflare Access requests authentication (email verification via browser)
- After successful authentication, it connects to the server’s SSH through the Tunnel
- The server’s SSH port remains closed throughout
Prerequisites
- Cloudflare Tunnel must already be configured (see Setting Up Cloudflare Tunnel)
cloudflaredmust be installed on your local machine
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
| Item | Standard SSH | Zero Trust SSH |
|---|---|---|
| Port exposure | Port 22 required | Not required |
| Authentication | Key-based only | Key-based + Cloudflare Access |
| Brute force | At risk | No attack surface |
| Access control | IP restriction at best | Flexible: email, IdP, country, etc. |
| Audit logs | Server-side only | Also recorded on Cloudflare |
| VPN | Sometimes required | Not 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_hostsis configured - Add
--protocol http2to 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.