Skip to content
← All posts

5 August 2026

Your WireGuard public key might outlive your VPN session

WireGuard is good at rotating keys. Just not the key most VPN apps use to recognise your device.

During a connection, WireGuard regularly replaces the symmetric keys that protect traffic. The static public key identifying each peer is a different thing. WireGuard leaves that key in place until something outside the protocol changes or removes it.

I started writing this as an explanation of Pangea's ephemeral peer design. The intended story was neat: make a fresh pair for every connection, send the public half to the server, then delete the peer at disconnect. Before publishing, we followed the actual code from the desktop app to the VPN node.

The code did not tell the neat story. Good. This version is more useful.

Why does the key stick around?

WireGuard has peers, not login sessions. Its configuration contains an interface private key and a list of peer public keys, each tied to allowed IP ranges and, sometimes, an endpoint.

The wg(8) manual tells an operator how to add and remove a peer. It has no concept of a customer logging out, a laptop going to sleep, or an app dying halfway through disconnect. The VPN has to supply all of that.

Keeping one peer per registered device is the obvious implementation. Generate a keypair, install the public half on a server, and reuse it next time. It survives restarts and bad wifi. It also means a control-plane outage does not stop an existing profile from reconnecting. There are solid operational reasons to do it this way.

WireGuard still rekeys the live tunnel every few minutes. Its protocol description is explicit about that. But those short-lived traffic keys are derived from the configured static identities. Rotating one does not rotate the other.

What can anyone do with a public key?

Less than the phrase "persistent identifier" might suggest. A WireGuard public key contains no name, email address, source IP, or browsing history. A passive observer cannot simply read it off the wire either; WireGuard encrypts the initiator's static identity during its Noise IK handshake.

The VPN operator is in a different position. Its provisioning service receives the public key, and the selected node needs the key in its peer configuration. The node will usually tie it to an allowed tunnel IP. Now there is a stable handle, even if it is a fairly bare one.

Compromise that node and a memory or disk capture may contain the peer key, its tunnel IP, recent handshake state, and whatever traffic metadata is available at that moment. That is not the same as finding a stored browsing history. It may still be enough to join the key to another record elsewhere.

The same distinction matters under legal compulsion. An operator can disclose or begin collecting state it controls. A bare public key becomes personally identifying only when it can be connected to an account, payment, device registration, or some other durable record.

Reuse across servers adds another join point. If the same public key appears on several nodes, someone who can inspect those nodes can recognise it. Fresh keys remove that one shortcut. They do not defeat timing analysis or make the account itself disappear.

What would actually ephemeral peers look like?

Generating a keypair is the easy bit. Cleanup is the design:

connect
  generate keypair locally
  authenticate provisioning request
  send public key only
  install peer on selected node with a short lease

disconnect
  remove peer from node
  discard local private key

lease expires
  remove peer even if the app crashed or lost its network

That expiry path matters more than the disconnect request. Laptops sleep. Processes get killed. Networks vanish. If cleanup depends on a polite logout, stale peers will accumulate.

The private key stays on the device. Only the public key goes to the selected node, under a short lease. Reconnect after expiry and the client makes another pair. Switch servers and the old key does not follow. Ideally the provisioning service never writes an account-to-session-key table at all.

None of this is free. New connections now depend on the control plane. A bad cleanup race can kill a working tunnel. Lease renewal adds another moving part. Calling keys "ephemeral" before designing the crash path is how stale credentials end up living forever.

What does our code actually do?

This was the uncomfortable part of the review.

Pangea's GPLv3 desktop client generates a fresh X25519 pair when it makes a new provisioning request. The private key builds the local WireGuard configuration and is not sent to the hub. The public key travels inside the encrypted request.

The hub does not save that public key in its PostgreSQL device record. The selected VPN node does save a narrower SQLite record: public key to assigned tunnel IP. There is no user ID, account ID, or timestamp in that node record.

So far, so good. Then we traced reconnects.

The desktop daemon persists the WireGuard configuration, private key included, in its local config file. A genuinely new provisioning attempt replaces the pair. A tray reconnect does not. Neither do network recovery, transport fallback, or a tunnel rebuild; those paths can reuse the stored profile. There is no scheduled rotation of the static key during a healthy connection.

Disconnect is the bigger problem. The app has no disconnect endpoint that removes the server peer. The node has no peer lease and no inactivity reaper. Close the app cleanly and the peer remains in SQLite and on the WireGuard interface. Pull the network cable and the result is the same.

The record is not necessarily permanent. Provisioning a replacement on the same node evicts the old peer for that tunnel IP. Removing a device or deprovisioning an account also attempts cleanup. A server switch attempts to clean the old node before installing the new peer, though that removal is best-effort.

Still, that is not a per-session peer. We cannot honestly describe a key recovered from node memory as single-use, and we cannot say every reconnect gets a new identity.

The logging answer is similarly untidy. Routine hub provisioning logs omit the peer key, but reconciliation logs can contain a full key and assigned IP when repairing unexpected state. The node logger is silent under the production setting we inspected. Host-level Docker or journal retention is not specified in the repo, so we cannot derive a retention period from the code.

The claim we can support is narrower: a fresh provisioning request creates a fresh peer key, the private half never leaves the device, and the central device record does not store the public half. That is better than one permanent account key. It is not the design I expected to describe when I opened the editor.

What would fixing this not fix?

Suppose we add short leases tomorrow and get every failure path right. We have removed one durable identifier. We have not built an anonymity network.

Anyone watching both sides of a VPN can correlate timing and volume without learning a WireGuard key. The VPN node sees traffic where it is forwarded. Its host or hypervisor may see encrypted traffic arrive and ordinary internet traffic leave the same single-hop machine.

Pangea currently runs single-hop on rented Vultr systems. Multi-hop across independent providers is not in production; the extra infrastructure cost has kept it deferred. Peer rotation cannot separate observations made by the same hosting provider.

Our engineering time has gone mostly into surviving networks that block VPNs. WireGuard traffic can travel through Cloak, VLESS with Reality, Hysteria2, or NaiveProxy. That changes what a censoring network sees. It does nothing to hide traffic from the VPN node, and it does not beat an observer watching both ends.

There has also been no independent audit. You can inspect the client source, including the exact persistence and reconnect paths discussed here. You cannot prove from a GitHub repository which server build is running or how its host is configured.

This is one fixable piece of credential hygiene. It is not an anonymity story, and we should not market it as one.

How can you check your provider?

Start on your own machine. If you have WireGuard configuration files, compare the interface public key after disconnecting, reconnecting, restarting the app, and switching servers. Where you control the interface, wg show prints the local public key and configured peer. Delete the device from the provider's dashboard and see whether the app produces a different key next time.

A closed client makes this harder, but you can still watch which files change and whether a new provisioning exchange occurs. With an open client, search for the key generator, then follow every call site. Find where the resulting configuration is written. Read the boring reconnect code. Comments such as "fresh per session" are not evidence; our own source had one that did not survive contact with the daemon.

The server side requires direct questions. Is the static key scoped to an account, a device, a server, a provisioning attempt, or an actual connection? Is it copied to other nodes? What removes it after normal disconnect? More importantly, what removes it when the laptop dies before sending one?

Ask where the key appears outside the live WireGuard interface too: central databases, node storage, logs, snapshots, support tooling. Then ask what identifier sits beside it.

You cannot prove remote deletion from your laptop. Source and a narrowly scoped audit can make a claim less flimsy, but some trust remains.

One question cuts through most of the wording: "What clears a stale peer after my laptop disappears, and how long does that take?"

If a provider cannot answer that, the practical answer may be nothing.