Zero Trust Architecture With WireGuard

The NIST Zero Trust Architecture publication (aka NIST SP 800-207) describes what “zero trust” means in the context of securing computer networks and information systems, and what a standard zero-trust architecture looks like. For many organizations, moving to a zero-trust architecture is not something you do overnight — it’s a gradual process of improving a system here, refining a policy there, gathering more data from this group of devices, and so on.

In this article, we’ll cover the basics of zero trust, and show you how you can start to migrate to a zero-trust architecture using WireGuard — even for systems and applications that don’t natively fit the paradigm:

Tenets of Zero Trust

What does zero trust mean? NIST SP 800-207 lays out the tenets of zero trust (in section 2.1). Here we’ll summerize each tenet with a short, pithy label, to make it easy to keep each principle in mind:

Everything is in-scope

(1) All data sources and computing services and considered resources.

Inventory everything: devices, systems, data sources, applications — internal and external. Include all the SaaS (Software as a Service), PaaS (Platform as a Service), IaaS (Infrastructure as a Service), and anything-else-as-a-service you use; as well as any externally-owned or operated devices that can access your internal resources.

Network is untrusted

(2) All communication is secured regardless of network location.

In other words, assume breach. Your adversaries are in your network and on your user devices (if not now, they will be at some point), so you need to design your internal infrastructure to prevent them from moving around and getting access to more systems and data.

Least-privileged access

(3) Access to individual enterprise resources is granted on a per-session basis.

Access to systems and data needs to be limited and granular: session-based, task-oriented, and time-bound.

Policy-based access control

(4) Access to resources is determined by dynamic policy — including the observable state of client identity, application/service, and the requesting asset — and may include other behavioral and environmental attributes.

Rather than static access grants enumerating each user and resource, access should be defined by higher-level policies that reflect business needs; and these policies should be evaluated dynamically using the most up-to-date attributes of the user and resource, as well as the observed behavior of the user, the device she’s using, the network environment as a whole, and the current threat landscape.

No device is trusted

(5) The enterprise monitors and measures the integrity and security posture of all owned and associated assets.

All devices (internal and external) need to be monitored for potential security risks — and access restricted based on risk.

Continuous access evaluation

(6) All resource authentication and authorization are dynamic and strictly enforced before access is allowed.

Authentication and authorization must be continually re-evaluated, to check not only for changes to the accessing user and her authorization, but also to account for incoming data on the behavior of the user, her device, the network environment, emerging threats, etc. Access must be cut off when something changes such that the user or her device no longer meets the policy requirements to access a resource.

Monitor and log everything

(7) The enterprise collects as much information as possible about the current state of assets, network infrastructure and communications, and uses it to improve its security posture.

Collect all the data you can about the user, her device, your network, and the global threat environment — and feed it all back into your access control.

Zero Trust Components

The core components of a zero trust architecture are pretty simple:

  1. A Subject (ie user) uses

  2. A System (ie local user device) to connect through

  3. A Policy Enforcement Point (PEP), which checks with

  4. A Policy Decision Point (PDP), composed of

    1. A Policy Administrator (PA), which communicates about access decisions (and manages implementation details like issuing credentials or managing session state) between the PEP and

    2. A Policy Engine (PE), which evaluates access policies to make the access decision; and if access is granted, the PEP allows the connection to continue on to

  5. A Resource (ie system, application, or collection of data), the object of the connection

A digram of an individual connection would look like this:

Zero Trust Architecture
Figure 1. Individual components of a zero-trust architecture

While the policies used by these zero-trust components should apply organization-wide, there may be many enforcement points (PEPs) spread out all over an organization’s network; and it might also have multiple decision points (PDPs) to handle access checks for different types of technologies or to handle checks at different physical locations:

Zero Trust In Practice
Figure 2. Zero-trust with multiple enforcement and decision points

Policy Enforcement For Web Apps

Policy enforcement for applications that use HTTP is generally much easier with a zero-trust architecture than applications that use other protocols. There are many off-the-shelf tools (nginx, Apache, HAProxy, etc) that can terminate TLS connections, decode the HTTP request, and allow you to plug in your zero-trust policy checks, before proxying it on to the appropriate application server if the checks pass.

While some web apps may need their authentication and authorization systems re-written to enable this, more and more web apps are being built from the ground up with pluggable systems that allow their authentication and authorization to be controlled through an HTTP proxy (or can customized to do so with a small amount of effort).

And many web applications that are run externally as SaaS allow for SSO (Single Sign-On) technologies, such as OIDC, OAuth2, or SAML, which similarly enable zero-trust policy enforcement.

Policy Enforcement for Other Apps

However, many applications or systems that users need to access are not available over HTTP. Common examples include file shares, printing, voice-over-IP (VoIP), video conferencing, remote desktop (RDP), database access, and terminal access (SSH).

Zero-trust policy enforcement for applications like this can be more difficult, as there generally isn’t off-the-shelf software that can proxy these applications natively to insert custom authentication and authorization into their access flow. In lieu of implementing a native proxy for each of these applications, what you can instead set up zero-trust policy enforcement via a virtual networking tool like WireGuard, paired with a traditional firewall like iptables or nftables.

WireGuard’s mandatory mutual authentication and cryptokey routing enables you to securely bind a private IP address to a device. This in turn allows you to enforce zero-trust policies for the device with an off-the-shelf firewall, using the WireGuard IP address to identify the device.

How to Get Started

Start with one application and the devices that use it. If the system to which users would normally connect to access the application can host WireGuard and an appropriate firewall, use it as the PEP (policy enforcement point). Otherwise set up another host as the PEP, install WireGuard and a firewall on it, and block off all inbound network connections to the application except through the PEP.

Select an available private subnet for the WireGuard network (we’ll use 10.0.0.0/24 in our example). Reserve one address for the PEP itself (10.0.0.1), and one address for the application (10.0.0.2). Designate an address on the WireGuard subnet for each device that needs to access the application. In our example, we’ll use 10.0.0.11 for Alice’s laptop, 10.0.0.12 for her phone, and 10.0.0.13 for Bob’s workstation.

To access the application, users will use the WireGuard network’s address for the application (10.0.0.2); and to identify each users’ device, our zero-trust infrastructure will use the WireGuard network’s address for the device (eg 10.0.0.11 for Alice’s laptop).

WireGuard Configuration

The WireGuard configuration for our PEP will look like this:

# local settings for the PEP
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51820

# remote settings for Alice's laptop
[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
AllowedIPs = 10.0.0.11

# remote settings for Alice's phone
[Peer]
PublicKey = jUd41n3XYa3yXBzyBvWqlLhYgRef5RiBD7jwo70U+Rw=
AllowedIPs = 10.0.0.12

# remote settings for Bob's workstation
[Peer]
PublicKey = MMsBeTT9v/7XNWB8a/jSMn9O8olPVNduUwvUPJ6eB14=
AllowedIPs = 10.0.0.13

And the WireGuard configuration for each device that needs to access the application will look like this (if the PEP’s public IP address is 198.51.100.10):

# local settings for Alice's laptop
[Interface]
PrivateKey = ABBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBFA=
Address = 10.0.0.11

# remote settings for the PEP
[Peer]
PublicKey = /TOE4TKtAqVsePRVR+5AA43HkAK5DSntkOCO7nYq5xU=
Endpoint = 198.51.100.10:51820
AllowedIPs = 10.0.0.0/24

(See the WireGuard Hub and Spoke Configuration guide for more details on each WireGuard configuration setting.)

Firewall Configuration

Configure the firewall on the PEP to:

  1. Translate the virtual address for the application to the real IP address of the application (from the perspective of the PEP itself)

  2. Block inbound connections to the application except from the WireGuard addresses of authorized devices

  3. Log all access to the application

If the PEP is on the same host as the application itself, a basic ruleset for a nftables firewall would look like this (if the application is for example an SMB fileshare):

#!/usr/sbin/nft -f
flush ruleset

define pub_iface = "eth0"
define wg_iface = "wg0"
define wg_port = 51820

define pep = 10.0.0.1
define fileshare_virt = 10.0.0.2
define fileshare_real = $pep
define fileshare_tcp = { 139, 445 }
define fileshare_udp = { 137, 138 }

define alices_laptop = 10.0.0.11
define alices_phone = 10.0.0.12
define bobs_workstation = 10.0.0.13

table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;
        # translate application's virtual address to real address
        ip daddr $fileshare_virt dnat ip to $fileshare_real
    }
}

table inet filter {
    # set of devices authorized to access SMB fileshare
    set device-access-to-fileshare {
        typeof ip saddr
        elements = { $alices_laptop, $alices_phone, $bobs_workstation }
    }

    chain device-access-policies {
        # log zero-trust network access
        log level debug prefix "device-access-policies: "
        # enforce zero-trust access policies
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real tcp dport $fileshare_tcp accept
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real udp dport $fileshare_udp accept
        reject with icmpx type admin-prohibited
    }

    chain input {
        type filter hook input priority 0; policy drop;

        # accept all loopback packets
        iif "lo" accept
        # accept all icmp/icmpv6 packets
        meta l4proto { icmp, ipv6-icmp } accept
        # accept all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }
        # drop new connections over rate limit
        ct state new limit rate over 1/second burst 10 packets drop

        # accept all DHCPv6 packets received at a link-local address
        ip6 daddr fe80::/64 udp dport dhcpv6-client accept
        # accept all WireGuard packets received on a public interface
        iifname $pub_iface udp dport $wg_port accept
        # filter packets inbound from WireGuard network through device access policies chain
        iifname $wg_iface goto device-access-policies

        # reject with polite "port unreachable" icmp response
        reject
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        reject with icmpx type host-unreachable
    }
}

This firewall configuration would allow Alice access the SMB fileshare using its virtual WireGuard IP address of 10.0.0.2 (eg smbclient //10.0.0.2/share1) from her laptop and phone when her WireGuard connection is up, and Bob from his workstation when his WireGuard connection is up.

(See the WireGuard With Nftables guide for more details how to configure nftables firewalls with WireGuard.)

PEP on a Different Host

If the PEP is on a different host than the application, change the forward chain in the above nftables rulset to jump to the device-access-policies chain for inbound access from the WireGuard network, and to allow it to forward returning packets for established connections:

    chain forward {
        type filter hook forward priority 0; policy drop;

        # forward all packets that are part of an already-established connection
        ct state vmap { invalid : drop, established : accept, related : accept }
        # drop new connections over rate limit
        ct state new limit rate over 1/second burst 10 packets drop

        # filter packets inbound from WireGuard network through device access policies chain
        iifname $wg_iface goto device-access-policies

        # reject with polite "host unreachable" icmp response
        reject with icmpx type host-unreachable
    }

Also change the definition for the application’s real IP address in the ruleset; in the above example, replace the $fileshare_real variable with the actual IP address of the application’s host (from the perspective of the PEP):

define fileshare_real = 192.168.1.123

Also, enable packet forwarding on the PEP host (eg sysctl -w net.ipv4.conf.all.forwarding=1); and either adjust the network between the PEP host and the application host to be able to route packets from the WireGuard network, or apply packet masquerading to the PEP’s nftables config by adding the following postrouting chain to its nat table:

table inet nat {
    chain prerouting {
        type nat hook prerouting priority -100; policy accept;
        # translate application's virtual address to real address
        ip daddr $fileshare_virt dnat ip to $fileshare_real
    }

    chain postrouting {
        type nat hook postrouting priority 100; policy accept;
        # masquerade WireGuard network addresses on LAN
        iifname $wg_iface oifname $pub_iface masquerade
    }
}

PEP Remote Access

Make sure you also lock down remote access to the PEP itself. The only port that should be remotely accessible is the WireGuard listen port (51820 in our example). Treat any needed remote access to the PEP as an application protected by the PEP itself: each device that needs access to the PEP must use the WireGuard network, where its access will be limited and logged by the firewall.

For example, to allow Alice to administer the PEP over SSH from her laptop, update the device-access-policies chain from the above example to the following:

    # set of devices authorized to access SMB fileshare
    set device-access-to-fileshare {
        typeof ip saddr
        elements = { $alices_laptop, $alices_phone, $bobs_workstation }
    }

    # set of devices authorized to access PEP itself for administration
    set device-access-to-pep-admin {
        typeof ip saddr
        elements = { $alices_laptop }
    }

    chain device-access-policies {
        # log zero-trust network access
        log level debug prefix "device-access-policies: "
        # enforce zero-trust access policies
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real tcp dport $fileshare_tcp accept
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real udp dport $fileshare_udp accept
        ip saddr @device-access-to-pep-admin ip daddr $pep tcp dport ssh accept
        reject with icmpx type admin-prohibited
    }

With this policy in place, and when the WireGuard interface on her laptop is up, Alice will be able to connect to the PEP over SSH using the PEP’s direct WireGuard address (eg via ssh 10.0.0.1).

Basic Management

The firewall rules we set up above represent the first iteration of our zero-trust access-control policies. Primitive though they be, they are powerful already: they assert that only Alice and Bob are authorized to access our application, and are authenticated through the possession of the WireGuard private keys registered for their devices.

Updating Policies

Later on you can connect these rules to a dynamic policy engine to strengthen them further; but initially you can just maintain a static ruleset in your configuration management system, and re-deploy them to the PEP when they change. Nftables is especially convenient for this — you can easily extract bits and pieces from your main ruleset definitions into external files that can be managed and updated separately.

For example, instead of enumerating the devices authorized to access each application in our main nftables.conf file, we could simply define empty sets for those devices in our main file, and use an include statement at the end of the main file to include additional files from a different directory (/etc/nftables.conf.d/ in this case):

#!/usr/sbin/nft -f
flush ruleset

# ...

table inet filter {
    # set of devices authorized to access SMB fileshare
    set device-access-to-fileshare { typeof ip saddr; }

    # set of devices authorized to access PEP itself for administration
    set device-access-to-pep-admin { typeof ip saddr; }

    chain device-access-policies {
        # log zero-trust network access
        log level debug prefix "device-access-policies: "
        # enforce zero-trust access policies
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real tcp dport $fileshare_tcp accept
        ip saddr @device-access-to-fileshare ip daddr $fileshare_real udp dport $fileshare_udp accept
        ip saddr @device-access-to-pep-admin ip daddr $pep tcp dport ssh accept
        reject with icmpx type admin-prohibited
    }

    # ...
}

include "/etc/nftables.conf.d/*.*"

Within the included directory, we could create a file like the following to populate the device-access-to-fileshare set, containing the WireGuard IP addresses of the devices authorized to access the fileshare:

# /etc/nftables.conf.d/device-access-to-fileshare.conf
table inet filter { set device-access-to-fileshare { typeof ip saddr; elements = {
    10.0.0.11,
    10.0.0.12,
    10.0.0.13,
}; }; }

And we could create a second file like the following to populate the device-access-to-pep-admin set, containing the WireGuard IP addresses of the devices authorized to administer the PEP via SSH:

# /etc/nftables.conf.d/device-access-to-pep-admin.conf
table inet filter { set device-access-to-pep-admin { typeof ip saddr; elements = {
    10.0.0.11,
}; }; }

You can store these smaller included files as-is in your configuration management system (SCM), or build them out on-the-fly from the policy rules and inventory list in your configuration management database. With this first iteration, we’re effectively using our SCM as our zero-trust policy engine (PE), and our CI/CD (Continuous Integration / Continuous Deploment) pipelines as the policy administrator (PA).

Tip

Use the nft -f command with your root nftables config file to atomically apply any updates you make to it, or to any included files, without disrupting existing connections:

$ sudo nft -f /etc/nftables.conf

(With most Linux distributions, this is what “restarting” the nftables service actually does behind the scenes.)

Alternatively, you can add IP addresses to existing nftables sets on-the-fly with the nft add element command; for example to add an IP address to the device-access-to-fileshare set defined in the filter table:

$ sudo nft add element inet filter device-access-to-fileshare { 10.0.0.14 }

Or remove IP address with the nft delete element command; for example to remove a couple of IP addresses from that same set:

$ sudo nft delete element inet filter device-access-to-fileshare { 10.0.0.13, 10.0.0.14 }

Adding User Devices

To add a brand new user or device to the application, you just need to generate a new public-key pair and WireGuard address for the new user’s device; add the device’s public key and IP address to the WireGuard config on the PEP:

# local settings for the PEP
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.1/24
ListenPort = 51820

# ...

# remote settings for Cate's workstation
[Peer]
PublicKey = kAYKIv6gpBDqueaVNOsY8ddKmIC2dnnXJQ0iKzWxfBI=
AllowedIPs = 10.0.0.14
Tip

Use the wg syncconf command in conjunction with the wg-quick strip command to reload your WireGuard configuration after adding a new peer to (or remove an old peer from) an active interface, without disrupting existing connections:

$ sudo bash -c 'wg syncconf wg0 <(wg-quick strip wg0)'

Alternatively, you can add and remove peers on-the-fly to an active WireGuard interface with the wg set command; for example to add the peer for Cate’s workstation to the wg0 interface:

$ sudo wg set wg0 peer kAYKIv6gpBDqueaVNOsY8ddKmIC2dnnXJQ0iKzWxfBI= allowed-ips 10.0.0.14

Or to remove that same peer from the wg0 interface:

$ sudo wg set wg0 peer kAYKIv6gpBDqueaVNOsY8ddKmIC2dnnXJQ0iKzWxfBI= remove

Monitoring Logs

Any log statements you have in your nftables ruleset will be logged to the kernel message facility. If you are already piping your kernel logs into your SIEM (Security Information and Event Management) system, and you log at the suggested points in the example nftables config above, you’ll have a record of every new connection attempted (rejected or accepted) within the WireGuard network.

Otherwise, you can have the host’s logging daemon push these logs into a custom log file or stream. For example, if you’re using rsyslogd, you can add the following line to your rsyslogd config to send messages containing the custom log prefix device-access-policies: (which we used in our example nftables config) to the /var/log/device-access-policies.log file:

# /etc/rsyslog.d/20-nftables.conf
:msg,contains,"device-access-policies:" /var/log/device-access-policies.log

Log entries will look like this, for example when Alice starts a new SMB connection from her laptop to the fileshare:

Aug 22 23:52:41 pep kernel: [98200.220647] device-access-policies: IN=wg0 OUT=eth0 MAC= SRC=10.0.0.11 DST=192.168.1.123 LEN=60 TOS=0x00 PREC=0x00 TTL=63 ID=41412 DF PROTO=TCP SPT=39122 DPT=445 WINDOW=64860 RES=0x00 SYN URGP=0

The Next Steps

Once you have a basic PEP (Policy Enforcement Point) with WireGuard and a firewall like nftables in place, you have a solid foundation for your zero-trust architecture on top of which you can layer stronger and more sophisticated policies, monitoring, and analysis. So the first thing you’ll want to do is apply this same architecture to any applications or systems that are currently not well secured.

Dynamic Policies

The next major improvement to make is to upgrade your PDP (Policy Decision Point) to make your PE (Policy Engine) more dynamic. If you don’t have some automation in place to automatically update each PEP’s firewall configuration when an administrator grants or revokes a user’s access to an application, do that first.

Then you may want to add some automation to temporarily block users or devices based on suspicious behavior. This could be something as simple as a custom script that periodically compiles a blocklist of suspicious IP addresses from your threat intelligence feeds and network activity logs, saves it in your SCM, and pushes it out to your PEPs via CI/CD pipeline.

However, you may want to eventually deploy a more specialized tool for defining and implementing complex policies, like OPA, Permify, Aserto, or a variety of other commercial products.

In an ideal world, you would have a single PE (replicated across multiple environments as necessary) that you can use uniformly for all your PEPs, and that is able to integrate all of the following into its access decisions:

  1. Your identity management system

  2. Your asset inventory

  3. Your data access policies

  4. Your network and system activity logs

  5. External threat intelligence feeds

  6. Your asset management and monitoring systems

  7. Any other security analysis tools (SIEM, XDR, etc) you use

More and Better Data

Once you have a PDP that can make use of more sophisticated sources of data, you can improve the quantity and variety of data you feed into it. You may want to fine-tune the firewall logging on your PEPs to provide the PDP with additional information, such as the public IP addresses used by the remote devices connecting to the PEPs (check out some of our other articles about monitoring WireGuard usage for more suggestions about this).

You should also make sure you have an endpoint management agent running on all devices allowed to connect to your PEPs. Such agents will be able to provide your PE with intelligence on the device’s patch level, the integrity of its system components, and any unusual or suspicious behavior its user has engaged in recently.

This will allow your PE to block access to applications from users who are otherwise authorized to use the application — but authorized only when you have high confidence that the user is actually who she says she is, and not an adversary who has compromised the user’s device. Adding WireGuard multi-factor authentication can also further strengthen your confidence in the authenticity of the device user’s identity.