Get started
Security

Zero-Trust Edge Networks: Replacing Legacy VPNs with WireGuard and Identity Proxies

Why a network-wide VPN is the wrong trust boundary, and how to replace it with an identity-aware proxy for web apps and narrowly scoped WireGuard tunnels for everything else, with configs, firewall rules and key rotation.

A traditional remote-access VPN answers one question: is this person allowed onto the network? Once the answer is yes, the laptop gets an address inside the perimeter and can usually reach far more than its owner needs: the wiki, the database, the hypervisor console and the printer. One phished password or one stolen laptop then opens all of it, and the attacker's traffic looks like ordinary internal traffic.

Zero trust replaces that single gate with a decision per request: who is asking, from which device, for which application, and is that allowed right now? This guide shows a practical way to get there with two well-understood building blocks: an identity-aware proxy for web applications and WireGuard for the smaller set of things that are not HTTP. It is a general design guide; adapt the names, addresses and identity provider to your own environment.

What you'll build

  • An identity-aware proxy in front of internal web apps. Every request is checked against a session issued by your identity provider (IdP) over OpenID Connect, and per-app rules decide who gets in.
  • A WireGuard gateway for SSH, databases and other non-HTTP access, where each device key can reach only the specific hosts and ports it needs.
  • A key lifecycle: per-device keys, scheduled rotation and fast revocation.
  • A migration path that lets you retire the old VPN one application at a time.

Key takeaways

  • Network location is not identity. Being on the tunnel should not grant access by itself.
  • WireGuard authenticates devices, not people. It is an excellent transport, but it has no users, groups, MFA or expiry, so pair it with identity checks and firewall policy.
  • An identity proxy is only as strong as the path behind it. Applications must accept traffic only from the proxy, or the proxy can be walked around.
  • Plan for the IdP being down. A tested break-glass path is part of the design, not an afterthought.

The architecture

                    +----------------------+
 browser ---------> | identity-aware proxy | ----> internal web apps
   |   HTTPS 443    |  (TLS, OIDC, policy) |       198.51.100.40-49
   |                +----------+-----------+
   |                           |
   +--- OIDC login ----> identity provider (MFA, groups)

 laptop / CI ------> WireGuard gateway --------> SSH, databases
   UDP 51820         vpn.example.com              198.51.100.20-39
                     tunnel 192.0.2.0/24          (per-peer firewall)

Addresses come from the documentation ranges: the tunnel network is 192.0.2.0/24, internal services sit in 198.51.100.0/24, and the gateway's public address is 203.0.113.5. The identity provider is shown as login.example.com.

Part 1: The identity-aware proxy

How the flow works

Products and open source projects in this space (OAuth2 Proxy, Pomerium and similar) differ in features, but the request flow is the same:

  1. A request arrives for grafana.example.com. The proxy looks for its session cookie.
  2. No valid session: the proxy redirects the browser to the IdP using the OIDC authorization code flow, ideally with PKCE.
  3. The user signs in at the IdP, including MFA. The IdP redirects back to the proxy's callback URL with a one-time code.
  4. The proxy exchanges the code for tokens and validates the ID token: signature against the IdP's published keys, issuer, audience and expiry.
  5. The proxy sets an encrypted, Secure, HttpOnly session cookie with a short lifetime.
  6. On every request, the proxy checks the session and evaluates policy (email domain, group membership, and device signals if your IdP supplies them), then forwards the request to the application with identity headers it sets itself.

The application never sees an unauthenticated request, and access to each app is a policy decision instead of a network-wide one.

A concrete setup: NGINX auth_request with OAuth2 Proxy

NGINX's auth_request module sends a subrequest to an authentication service before serving each request; a 2xx response allows it, a 401 or 403 denies it. OAuth2 Proxy is designed to sit behind it. The configuration below matches OAuth2 Proxy v7.x; option names have changed across major versions, so check oauth2-proxy --help on yours.

Register a confidential OIDC client at your IdP with the redirect URI https://grafana.example.com/oauth2/callback, then create /etc/oauth2-proxy/oauth2-proxy.cfg:

provider = "oidc"
oidc_issuer_url = "https://login.example.com"
client_id = "internal-apps"
client_secret_file = "/etc/oauth2-proxy/client-secret"
cookie_secret_file = "/etc/oauth2-proxy/cookie-secret"
code_challenge_method = "S256"

http_address = "127.0.0.1:4180"
reverse_proxy = true
redirect_url = "https://grafana.example.com/oauth2/callback"
upstreams = ["static://202"]
set_xauthrequest = true

email_domains = ["example.com"]
allowed_groups = ["grafana-users"]

cookie_secure = true
cookie_samesite = "lax"
cookie_expire = "8h"
cookie_refresh = "1h"
skip_provider_button = true

Generate the cookie secret and lock down both secret files:

sudo install -d -m 700 /etc/oauth2-proxy
openssl rand -base64 24 | sudo tee /etc/oauth2-proxy/cookie-secret >/dev/null
sudo chmod 600 /etc/oauth2-proxy/cookie-secret /etc/oauth2-proxy/client-secret

OAuth2 Proxy uses the contents of the cookie secret file as the key, so it must be exactly 16, 24 or 32 characters long; 24 random bytes encoded as base64 give 32. The client-secret file holds the secret your IdP issued. Group checks depend on your IdP including a groups claim in the ID token; many need that enabled per client.

Then the NGINX server block:

server {
    listen 443 ssl;
    http2 on;
    server_name grafana.example.com;

    ssl_certificate     /etc/nginx/tls/grafana.example.com.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/grafana.example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    location /oauth2/ {
        proxy_pass http://127.0.0.1:4180;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Auth-Request-Redirect $request_uri;
    }

    location = /oauth2/auth {
        internal;
        proxy_pass http://127.0.0.1:4180;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Uri $request_uri;
        proxy_set_header Content-Length "";
        proxy_pass_request_body off;
    }

    location / {
        auth_request /oauth2/auth;
        error_page 401 = /oauth2/sign_in;

        auth_request_set $auth_user  $upstream_http_x_auth_request_user;
        auth_request_set $auth_email $upstream_http_x_auth_request_email;

        proxy_set_header X-Auth-Request-User  $auth_user;
        proxy_set_header X-Auth-Request-Email $auth_email;
        proxy_set_header Host $host;
        proxy_pass http://198.51.100.40:3000;
    }
}

http2 on; needs NGINX 1.25.1 or later; on older versions use listen 443 ssl http2;. The auth_request module is compiled into most distribution packages; nginx -V lists --with-http_auth_request_module if yours has it.

Close the side door

The proxy only protects what cannot be reached around it. Three rules:

  • The application listens only where the proxy can reach it. Bind it to an internal address and firewall it so that only the proxy's address can connect on port 3000.
  • The proxy owns the identity headers. proxy_set_header replaces any X-Auth-Request-User a client sends, but headers you do not set pass through untouched. If the app trusts a header for login, make sure that exact header is always set by the proxy.
  • Prefer verifiable identity over bare headers. Where the proxy can forward a signed token (a JWT the application verifies against the proxy's or IdP's public key) or connect to the app with mutual TLS, use that. A header is only as trustworthy as the network path it travelled.

Part 2: WireGuard for everything that is not HTTP

How WireGuard decides what to accept

WireGuard identifies each peer by its Curve25519 public key. The AllowedIPs list on each peer does two jobs at once, which WireGuard calls cryptokey routing:

  • Outbound, it is a routing table: a packet for an address in a peer's AllowedIPs is encrypted to that peer.
  • Inbound, it is a source filter: a decrypted packet is accepted only if its inner source address is in the sending peer's AllowedIPs.

On the gateway, each device peer gets a single /32, so a device cannot impersonate another device's tunnel address. That gives you reliable attribution: a tunnel address in a firewall log maps to exactly one key.

Generate keys

On each device, generate its own key pair so the private key never leaves it:

umask 077
wg genkey | tee private.key | wg pubkey > public.key

Only public.key is sent to the gateway administrator. For each peer, the administrator also creates a preshared key with wg genpsk, which adds a symmetric key to the handshake as an extra layer against future attacks on the public-key exchange.

The gateway: /etc/wireguard/wg0.conf

[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = <gateway-private-key>

[Peer]
# alice-laptop, issued 2026-10-20
PublicKey = <alice-laptop-public-key>
PresharedKey = <alice-laptop-psk>
AllowedIPs = 192.0.2.10/32

[Peer]
# ci-runner-1, issued 2026-10-20
PublicKey = <ci-runner-1-public-key>
PresharedKey = <ci-runner-1-psk>
AllowedIPs = 192.0.2.20/32

A device: /etc/wireguard/wg0.conf

[Interface]
Address = 192.0.2.10/32
PrivateKey = <alice-laptop-private-key>

[Peer]
PublicKey = <gateway-public-key>
PresharedKey = <alice-laptop-psk>
Endpoint = vpn.example.com:51820
AllowedIPs = 198.51.100.0/24
PersistentKeepalive = 25
  • Split tunnel by default. AllowedIPs = 198.51.100.0/24 sends only internal traffic through the tunnel. 0.0.0.0/0, ::/0 would send everything, which turns the gateway back into a general-purpose VPN.
  • PersistentKeepalive = 25 makes the device send a keepalive every 25 seconds when otherwise idle, which keeps a NAT or stateful firewall mapping open so the gateway can reach it. Set it on the side behind NAT; the gateway, with a public address, does not need it. An empty keepalive is 32 bytes of WireGuard data plus 8 bytes of UDP and 20 of IPv4, 60 bytes in all, so the cost is 60 / 25 = 2.4 bytes per second.
  • MTU. If you do not set one, wg-quick on Linux takes the MTU of the interface it would use to reach the endpoint (or of the default route) and subtracts 80: 40 for an IPv6 outer header (the worst case), 8 for UDP and 32 for WireGuard. On a 1500-byte link that gives 1420.

Bring the tunnel up with sudo wg-quick up wg0, or sudo systemctl enable --now wg-quick@wg0 to start it at boot.

Per-peer policy on the gateway

The tunnel proves which device sent a packet. The gateway's firewall decides what that device may reach. Enable forwarding with net.ipv4.ip_forward = 1 in /etc/sysctl.d/, then default-deny the forward path:

table inet ztna {
    chain forward {
        type filter hook forward priority 0; policy drop;

        ct state established,related accept

        iifname "wg0" ip saddr 192.0.2.10 ip daddr 198.51.100.20 tcp dport 22 accept
        iifname "wg0" ip saddr 192.0.2.20 ip daddr 198.51.100.30 tcp dport 5432 accept

        iifname "wg0" log prefix "ztna-deny " drop
    }
}

Alice's laptop can open SSH to one host. The CI runner can reach one database port. Nothing else crosses the gateway, and denied attempts are logged with the source tunnel address.

Route the tunnel network back through the gateway from the internal hosts instead of masquerading. Without NAT, the database and SSH logs show 192.0.2.20 and 192.0.2.10, which map to named keys; with NAT, every connection appears to come from the gateway.

Add identity to SSH

A WireGuard key says which laptop connected, not who is using it. For SSH, add short-lived OpenSSH user certificates signed after an IdP login. The signing step looks like this:

ssh-keygen -s /secure/ssh-user-ca -I [email protected] -n alice -V +8h id_ed25519.pub

That produces id_ed25519-cert.pub, valid for eight hours, for the principal alice only. Servers trust the CA through TrustedUserCAKeys in sshd_config. Stolen certificates then expire on their own, and the CA's private key belongs on a system with tighter access than any server it signs for.

Key lifecycle

  • One key per device, never shared between people or machines. The comment above each [Peer] records the owner and issue date.
  • Rotate on a schedule you can sustain, such as every 90 days, and immediately when a device is lost. Rotation means a new key pair on the device, a new [Peer] on the gateway, then removal of the old one.
  • Revoke in seconds. Remove a peer from the running interface, then from the file:
sudo wg set wg0 peer '<alice-laptop-public-key>' remove
sudo bash -c 'wg syncconf wg0 <(wg-quick strip wg0)'

The second command, run after deleting the [Peer] block from the file, reloads it without dropping other peers' sessions. It runs inside bash -c because sudo closes the file descriptor that <( ) creates. Disable the IdP account at the same time. With an IdP that issues refresh tokens, the proxy's next refresh (cookie_refresh) fails and the session ends; otherwise it lasts until cookie_expire, which is why both are kept short.

Migrating off the old VPN

  1. Inventory what people reach through the VPN today. Flow logs or firewall logs usually show that most traffic goes to a handful of web apps.
  2. Move web apps behind the identity proxy one at a time, keeping the VPN route as a fallback until each one is confirmed.
  3. Issue WireGuard keys only to people and systems that need non-HTTP access, each with a narrow firewall rule.
  4. Shrink the old VPN's reach route by route, watching its logs for anything still in use.
  5. Switch it off when the logs are quiet, and remove its accounts and firewall openings.

Failure modes and troubleshooting

SymptomLikely cause and fix
wg show lists no "latest handshake"UDP 51820 is blocked, the endpoint name resolves wrongly, or a public key is pasted on the wrong side. Check sudo tcpdump -ni eth0 udp port 51820 on the gateway.
Handshake works, traffic does notThe destination is missing from the device's AllowedIPs, the gateway's forward rule is missing, or internal hosts have no return route to 192.0.2.0/24.
Two devices fight over one addressTwo peers list the same AllowedIPs. WireGuard moves the address to the peer configured last. Give each peer a unique /32.
Small requests work, large ones hangAn MTU problem on the path. Lower the tunnel MTU and clamp TCP MSS; the tunneling overhead guide shows the arithmetic.
Endless redirect between app and IdPThe cookie is not being set: cookie_secure over plain HTTP, a wrong redirect_url, or a cookie domain that does not match the host.
Valid users get 403The IdP is not sending the groups claim, or the email domain does not match email_domains. Check the proxy's log for the reason.
Logins fail with token errorsClock skew between proxy and IdP. Run NTP on every host.
Nobody can sign inThe IdP is down. Use the documented break-glass account, protected by a hardware key and stored offline, and review every use.

Checklist

  • Web apps sit behind the identity proxy and accept connections only from it.
  • The proxy sets every identity header the app trusts; secrets are mode 600; session lifetime is short.
  • Each WireGuard peer has its own key, its own /32 and a comment naming its owner and issue date.
  • Devices use split tunnels; PersistentKeepalive is set only where NAT requires it.
  • The gateway's forward chain defaults to drop, allows per-peer host and port pairs, and logs denials.
  • No NAT on the gateway, so internal logs show per-device addresses.
  • SSH uses short-lived certificates from a CA with restricted access.
  • Rotation and revocation are written down and have been rehearsed.
  • A break-glass path exists, is tested, and its use is reviewed.

Closing

Zero trust is less a product than a change in where decisions are made: per request and per application, based on identity, instead of once at the network edge. An identity proxy and narrowly scoped WireGuard tunnels get you most of the way with tools that are well documented and widely deployed. Start with the web app that the most people reach through the old VPN; it usually removes most of the VPN's traffic in one step.