Reverse Tunnel
Securely access private services behind NAT/firewall without exposing them to the public internet. Perfect for accessing databases, internal APIs, and IoT devices on private networks from anywhere.
Overview
A reverse tunnel allows you to access services on a private network (behind NAT or firewall) without opening inbound firewall ports or exposing services directly to the internet. Unlike traditional tunnels that expose local services publicly, reverse tunnels create a secure bridge that only authorized clients can use.
Perfect for:
- Private Databases: Access databases on home networks or private clouds without opening firewall ports
- Internal APIs: Reach internal services without VPN complexity
- IoT Devices: Manage devices behind NAT (cameras, sensors, home automation)
- Remote Administration: Access development machines, home servers, or internal infrastructure
Key Features:
- Zero inbound firewall rules required on private network
- End-to-end encryption via QUIC (TLS 1.3)
- JWT-based authentication for both agent and client
- Works through NAT, firewalls, and restrictive networks
- No public exposure of private services
Security Architecture
Reverse tunnels use a three-party architecture with defense-in-depth security:
┌──────────────────────────────────────────────────────────────────┐
│ Security Architecture │
└──────────────────────────────────────────────────────────────────┘
Client Relay Agent Private Service
(Laptop) (Public Server) (Private Network) (Database)
│ │ │ │
│ ①──QUIC/TLS 1.3──→│←──QUIC/TLS 1.3───② │ │
│ (JWT Auth) │ (JWT Auth) │ │
│ │ │ │
│ ③ Request Data ───→│─── Forward ───────→│─── ④ Connect ──→│
│ │ │ (localhost) │
│ │ │ │
│←─── Response ───────│←─── Response ──────│←─── Data ────────│
│ │ │ │
┌─────────────────────────────────────────────────────────────────┐
│ Security Layers: │
│ ① Client → Relay: QUIC encrypted, JWT authenticated │
│ ② Agent → Relay: QUIC encrypted, JWT authenticated │
│ ③ Relay never directly accesses private service │
│ ④ Agent only accepts localhost connections │
└─────────────────────────────────────────────────────────────────┘
Security Flow:
- Agent Registration: Agent (on private network) connects to public relay with JWT token, creates persistent QUIC connection
- Client Authentication: Client connects to relay with separate JWT token, requests access to specific agent
- Authorization Check: Relay verifies both tokens, ensures client is authorized to access that agent
- Secure Tunneling: Relay bridges the two QUIC connections without decrypting payload
- Local Forwarding: Agent forwards traffic to local service (localhost only)
Why This is Secure:
- No Public Exposure: Private service never listens on public internet
- Mutual Authentication: Both agent and client must present valid JWT tokens
- End-to-End Encryption: All traffic encrypted with QUIC (TLS 1.3)
- Localhost-Only: Agent only connects to localhost, preventing lateral movement
- Token Isolation: Separate tokens for agent and client (different scopes)
- No Inbound Ports: Agent initiates outbound connection to relay (NAT/firewall friendly)
How It Works
Traditional Tunnel vs Reverse Tunnel
Traditional Tunnel (Public Exposure):
Local Service → Client → Relay → Internet
:3000 QUIC :443 Anyone can access
https://myapp.relay.com
☝️ Problem: Your service is exposed to the entire internet.
Reverse Tunnel (Private Access):
Private Service ← Agent ← Relay ← Client
:5432 localhost :4443 Authenticated
(private) (QUIC) only you
☝️ Solution: Only authorized clients can access through relay.
Connection Flow
-
Agent Startup (Private Network):
# Agent runs on private network, connects to relay localup agent \ --relay relay.example.com:4443 \ --agent-id "private-db" \ --target-address "localhost:5432" \ --token "$AGENT_TOKEN"- Agent establishes persistent QUIC connection to relay
- Registers as
agent-id: private-db - Waits for client connections
-
Client Connection (Anywhere):
# Client connects from anywhere, authenticated with token localup connect \ --relay relay.example.com:4443 \ --agent-id "private-db" \ --local-address "localhost:19432" \ --token "$CLIENT_TOKEN"- Client authenticates with relay using JWT
- Requests access to agent
private-db - Relay verifies authorization and bridges connections
-
Data Flow:
Client App → localhost:19432 → Client → Relay → Agent → localhost:5432 → Private Service psql (local) (QUIC) (QUIC) (localhost) PostgreSQL
Key Points:
- Private service never listens on public internet
- Agent only accepts localhost connections
- Relay cannot decrypt traffic (QUIC end-to-end encryption)
- Client must authenticate with valid JWT token
Security Guarantees
1. No Public Exposure
Guarantee: Private services never accept connections from the internet.
- Agent only connects to
localhost(127.0.0.1) - No inbound firewall rules required
- Service remains invisible to port scanners
Example:
# Private service bound to localhost only
postgres -c listen_addresses='127.0.0.1'
# Agent forwards to localhost only
localup agent --target-address "localhost:5432"
2. Mutual Authentication
Guarantee: Both agent and client must present valid JWT tokens.
- Agent Token: Authenticates agent to relay, proves identity
- Client Token: Authenticates client to relay, authorizes access to specific agent
- Tokens use HMAC-SHA256 signatures (HS256 algorithm)
- Tokens have expiration times (
expclaim) enforced by relay
Token Structure:
{
"sub": "agent-id or client-id",
"exp": 1735689600,
"iat": 1704067200
}
Token Verification:
# Generate agent token (long-lived for persistent connection)
localup generate-token --secret "my-secret" --sub "agent-id" --token-only
# Generate client token (short-lived for access control)
localup generate-token --secret "my-secret" --sub "client-id" --token-only
3. End-to-End Encryption
Guarantee: All traffic encrypted with QUIC (TLS 1.3), relay cannot decrypt.
- QUIC Protocol: Modern transport protocol with built-in TLS 1.3 encryption
- TLS 1.3: Latest TLS version with forward secrecy (ECDHE key exchange)
- Perfect Forward Secrecy: Compromise of long-term keys doesn't decrypt past sessions
- Zero-Knowledge Relay: Relay only routes encrypted packets, cannot inspect payload
Encryption Layers:
Application Data (e.g., SQL query)
↓ Encrypted at: Client → QUIC/TLS 1.3 → Relay (encrypted) → Agent → Decrypted
↓ Relay sees: [ENCRYPTED QUIC PACKETS]
↓ Agent decrypts and forwards to localhost
Private Service (e.g., PostgreSQL)
4. Network Isolation
Guarantee: Agent only accepts connections from localhost, preventing lateral network access.
- Agent binds target address as
localhost:PORT - Cannot reach other machines on private network
- Prevents pivot attacks or lateral movement
- Limits blast radius if agent is compromised
Example:
# ✅ Secure: Agent forwards to localhost only
localup agent --target-address "localhost:5432"
# ❌ Insecure: Agent could access entire network
# localup agent --target-address "192.168.1.50:5432" # Don't do this!
Best Practice: Always use localhost or 127.0.0.1 as target address.
5. Token-Based Authorization
Guarantee: Client must provide valid token to access agent, relay enforces authorization.
- Relay verifies client token signature and expiration
- Client token's
subclaim must match authorized agent IDs - Token revocation possible by rotating JWT secret
- Short-lived tokens reduce risk of token theft
Authorization Model:
# Agent registers with token (sub: agent-id)
AGENT_TOKEN=$(localup generate-token --secret "secret" --sub "private-db" --token-only)
localup agent --agent-id "private-db" --token "$AGENT_TOKEN" ...
# Client must have valid token to connect
CLIENT_TOKEN=$(localup generate-token --secret "secret" --sub "client-1" --token-only)
localup connect --agent-id "private-db" --token "$CLIENT_TOKEN" ...
Token Expiration:
- Agent tokens: Long-lived (days/weeks) for persistent connections
- Client tokens: Short-lived (hours) for access control
- Rotate tokens regularly to limit exposure
Setup Guide
Prerequisites
- Relay Server (Public): A publicly accessible server running the LocalUp relay
- Agent (Private Network): Machine on private network running the agent
- Client (Anywhere): Your laptop or machine connecting to the private service
- JWT Secret: Shared secret for token generation
Step 1: Start the Relay Server (Public)
# On public server (relay.example.com)
localup relay tcp \
--localup-addr "0.0.0.0:4443" \
--tcp-port-range "10000-20000" \
--jwt-secret "your-production-secret"
Security Note: Keep --jwt-secret confidential. Anyone with this secret can generate valid tokens.
Step 2: Run the Agent (Private Network)
# On private network (e.g., home network, private cloud)
# Generate agent token
export AGENT_TOKEN=$(localup generate-token --secret "your-production-secret" --sub "private-db" --token-only)
# Start agent to expose private service
localup agent \
--relay relay.example.com:4443 \
--agent-id "private-db" \
--target-address "localhost:5432" \
--token "$AGENT_TOKEN"
What happens:
- Agent connects to relay at
relay.example.com:4443via QUIC - Authenticates with
AGENT_TOKEN - Registers as
agent-id: private-db - Waits for client connections
- Forwards traffic to
localhost:5432(PostgreSQL)
Security Best Practices:
- Use
localhostor127.0.0.1for--target-addressto prevent lateral movement - Use a unique
--agent-idfor each service - Rotate
AGENT_TOKENregularly (weekly/monthly) - Run agent with minimal privileges (non-root user)
Step 3: Connect as Client (Anywhere)
# On client machine (anywhere on internet)
# Generate client token
export CLIENT_TOKEN=$(localup generate-token --secret "your-production-secret" --sub "client-1" --token-only)
# Connect to private service through relay
localup connect \
--relay relay.example.com:4443 \
--agent-id "private-db" \
--local-address "localhost:19432" \
--remote-address "localhost:5432" \
--token "$CLIENT_TOKEN"
What happens:
- Client connects to relay at
relay.example.com:4443via QUIC - Authenticates with
CLIENT_TOKEN - Requests connection to
agent-id: private-db - Relay bridges client and agent connections
- Client binds local port
19432→ forwards to agent →localhost:5432
Step 4: Access the Private Service
# Connect to private PostgreSQL via local port
psql -h localhost -p 19432 -U postgres
# Or with connection string
psql "postgresql://postgres@localhost:19432/mydb"
What you're doing:
- Connecting to
localhost:19432on your client machine - LocalUp client forwards to relay
- Relay forwards to agent on private network
- Agent forwards to
localhost:5432(PostgreSQL)
Zero inbound firewall rules needed on private network!
Complete Example
Here's a full end-to-end example with a private PostgreSQL database:
Scenario
- Private Network: Home network with PostgreSQL at
192.168.1.100:5432 - Relay: Public server at
relay.example.com - Client: Your laptop anywhere on the internet
- Goal: Access PostgreSQL from laptop without opening firewall ports
Terminal 1: Start Relay (Public Server)
# On relay.example.com (public server)
localup relay tcp \
--localup-addr "0.0.0.0:4443" \
--tcp-port-range "10000-20000" \
--jwt-secret "my-production-secret-change-me"
Terminal 2: Run Agent (Private Network)
# On machine in private network (192.168.1.100)
# 1. Generate agent token (long-lived)
export AGENT_TOKEN=$(localup generate-token \
--secret "my-production-secret-change-me" \
--sub "home-postgres" \
--token-only)
# 2. Start agent to expose PostgreSQL
localup agent \
--relay relay.example.com:4443 \
--agent-id "home-postgres" \
--target-address "localhost:5432" \
--token "$AGENT_TOKEN"
# Agent output:
# ✅ Connected to relay: relay.example.com:4443
# ✅ Agent ID: home-postgres
# ✅ Waiting for client connections...
Security Notes:
- Agent uses
localhost:5432not192.168.1.100:5432 - This ensures agent can only access local services
- Agent runs with minimal privileges (non-root user)
Terminal 3: Connect as Client (Laptop)
# On your laptop (anywhere on internet)
# 1. Generate client token (short-lived)
export CLIENT_TOKEN=$(localup generate-token \
--secret "my-production-secret-change-me" \
--sub "laptop-client" \
--token-only)
# 2. Connect to private PostgreSQL
localup connect \
--relay relay.example.com:4443 \
--agent-id "home-postgres" \
--local-address "localhost:19432" \
--remote-address "localhost:5432" \
--token "$CLIENT_TOKEN"
# Client output:
# ✅ Connected to relay: relay.example.com:4443
# ✅ Connected to agent: home-postgres
# ✅ Tunnel ready: localhost:19432 → private service
Terminal 4: Access Database
# Connect to private PostgreSQL via local port
psql -h localhost -p 19432 -U postgres
# Or use GUI tool (e.g., pgAdmin, DBeaver)
# Host: localhost
# Port: 19432
# User: postgres
What's happening:
- Your laptop connects to
localhost:19432(local port) - LocalUp client encrypts and sends to relay via QUIC
- Relay forwards to agent on private network (still encrypted)
- Agent decrypts and forwards to
localhost:5432(PostgreSQL) - PostgreSQL responds back through same path
Security:
- ✅ No inbound firewall rules on private network
- ✅ End-to-end encryption (QUIC/TLS 1.3)
- ✅ Both agent and client authenticated with JWT
- ✅ Agent only accesses localhost (no lateral movement)
- ✅ Private service never exposed to internet
Security Best Practices
1. Use Localhost-Only Target Addresses
Why: Prevents lateral movement attacks if agent is compromised.
# ✅ Good: Agent can only access localhost
localup agent --target-address "localhost:5432"
# ❌ Bad: Agent can access entire private network
localup agent --target-address "192.168.1.100:5432"
Principle: Minimize blast radius by limiting what agent can access.
2. Rotate JWT Tokens Regularly
Why: Limits exposure if tokens are stolen or leaked.
Recommendation:
- Agent Tokens: Rotate monthly (or when agent is compromised)
- Client Tokens: Rotate weekly or use short-lived tokens (hours)
- JWT Secret: Rotate quarterly, requires restarting relay and regenerating all tokens
Token Rotation Workflow:
# 1. Generate new token with updated secret
NEW_TOKEN=$(localup generate-token --secret "new-secret" --sub "agent-id" --token-only)
# 2. Update relay with new secret (restart required)
localup relay tcp --jwt-secret "new-secret" ...
# 3. Update agent with new token
localup agent --token "$NEW_TOKEN" ...
3. Use Strong JWT Secrets
Why: Weak secrets allow attackers to forge tokens.
Requirement:
- Minimum 32 characters
- Random, high-entropy (use password generator)
- Never commit to version control
- Store in secrets manager (e.g., AWS Secrets Manager, HashiCorp Vault)
Generate Strong Secret:
# Good: 32+ random characters
openssl rand -base64 32
# Output: k3jF9xR2pQ4vN8mL5dH7wZ1cT6eS9yA0...
# ❌ Bad: Weak/guessable secrets
--jwt-secret "password123"
--jwt-secret "my-secret"
4. Limit Token Scope
Why: Principle of least privilege - tokens should only access what they need.
Recommendation:
- Agent Tokens: Use
--subto identify agent (e.g.,--sub "prod-db-agent") - Client Tokens: Use
--subto identify client (e.g.,--sub "alice-laptop") - Future enhancement: Add
scopeclaim to limit which agents a client can access
Example:
# Agent token for production database
AGENT_TOKEN=$(localup generate-token --secret "secret" --sub "prod-db-agent" --token-only)
# Client token for Alice (future: add scope for specific agents)
CLIENT_TOKEN=$(localup generate-token --secret "secret" --sub "alice-laptop" --token-only)
5. Run Agent with Minimal Privileges
Why: Limits damage if agent is compromised.
Recommendations:
- Run agent as non-root user
- Use systemd to manage agent lifecycle
- Restrict file system access with AppArmor/SELinux
- Use containers or VMs for isolation
Example (systemd service):
[Unit]
Description=LocalUp Agent for Private Database
After=network.target
[Service]
Type=simple
User=localup # Non-root user
Group=localup
ExecStart=/usr/local/bin/localup agent \
--relay relay.example.com:4443 \
--agent-id "private-db" \
--target-address "localhost:5432" \
--token "${AGENT_TOKEN}"
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=multi-user.target
6. Monitor and Audit Access
Why: Detect unauthorized access attempts and anomalies.
Recommendations:
- Enable relay logging (
--log-level debug) - Monitor failed authentication attempts
- Track which clients connect to which agents
- Set up alerts for suspicious activity
Example (relay logs):
INFO Agent connected: agent-id=private-db, ip=192.168.1.100
INFO Client authenticated: client-id=alice-laptop, token-sub=alice
WARN Authentication failed: invalid token signature, ip=203.0.113.45
7. Use Firewall Rules on Relay
Why: Defense in depth - limit who can connect to relay.
Recommendations:
- Restrict relay port (4443) to known IP ranges if possible
- Use cloud provider security groups (AWS Security Groups, GCP Firewall Rules)
- Enable fail2ban for brute-force protection
Example (AWS Security Group):
Inbound Rule:
- Protocol: UDP
- Port: 4443
- Source: 0.0.0.0/0 (QUIC uses UDP)
- Description: LocalUp relay control plane
8. Validate Target Addresses
Why: Prevent SSRF (Server-Side Request Forgery) attacks.
Recommendations:
- Hardcode target addresses in agent configuration
- Validate target addresses are localhost/127.0.0.1
- Reject private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
Future Enhancement:
# Agent validates target address at startup
localup agent --target-address "localhost:5432" --validate-localhost
# Error if target is not localhost/127.0.0.1
Troubleshooting
"Connection refused" when agent connects to relay
Cause: Relay is not running or firewall is blocking QUIC.
Solution:
# Verify relay is running
lsof -i :4443
# Check firewall allows UDP (QUIC uses UDP)
sudo ufw allow 4443/udp
# Test connectivity from agent network
nc -u relay.example.com 4443
"Authentication failed" on agent startup
Cause: Agent token doesn't match relay's --jwt-secret.
Solution:
# Regenerate token with correct secret
AGENT_TOKEN=$(localup generate-token \
--secret "correct-secret" \
--sub "agent-id" \
--token-only)
# Verify secret matches relay's --jwt-secret flag
"Agent not found" when client connects
Cause: Agent is not running or --agent-id mismatch.
Solution:
# Verify agent is running and registered
# Check relay logs for: "Agent connected: agent-id=..."
# Ensure client's --agent-id matches agent's --agent-id
localup connect --agent-id "private-db" ... # Must match agent
Client tunnel hangs or disconnects
Cause: Network interruption, relay restart, or token expiration.
Solution:
# Check client logs for error messages
# Verify relay is still running
lsof -i :4443
# Regenerate tokens if expired
CLIENT_TOKEN=$(localup generate-token --secret "secret" --sub "client" --token-only)
# Restart client connection
localup connect --token "$CLIENT_TOKEN" ...
"Permission denied" when accessing local port
Cause: Local port binding failed or port already in use.
Solution:
# Check if port is in use
lsof -i :19432
# Use a different local port
localup connect --local-address "localhost:19433" ...
# Or kill the process using the port
kill -9 <PID>
Agent cannot connect to private service
Cause: Private service not running or target address incorrect.
Solution:
# Verify private service is running
lsof -i :5432 # On agent machine
# Test local connectivity
telnet localhost 5432
# Verify target address is correct
localup agent --target-address "localhost:5432" ... # Not 192.168.x.x
Next Steps: