The iPhone camera wanted to Google my private key

Every WireGuard walkthrough ends the same way: "generate the config, render it as a QR code, scan it with your phone." None of them tell you which app does the scanning, because the author scanned it inside the WireGuard app and it never occurred to them you might not. I pointed an iPhone's Camera app at a freshly generated tunnel QR, and iOS did something helpful and catastrophic: it read the code as plain text and offered me a Google search — of the entire config, private key included.

That's the footgun. Everything else here is why WireGuard was worth the risk of finding it.

Why add WireGuard next to a working VPN#

The MikroTik already had L2TP/IPsec. It worked, in the sense that a thing you have to coax into working every time works. L2TP is two protocols stapled together — L2TP for the tunnel, IPsec for the encryption — which means NAT traversal, ESP, and a pre-shared key all have to line up, and every client OS has its own opinion about how. Behind the ISP's address mapping it was fragile in exactly the way that eats an evening whenever a new device shows up.

RouterOS 7 has native WireGuard. One UDP port, one keypair per peer, and a protocol simple enough that "does it work" has a yes/no answer instead of a differential diagnosis. The server side is short:

/interface/wireguard/add name=wg-vpn listen-port=51820
/ip/address/add address=10.8.0.1/24 interface=wg-vpn
/interface/list/member/add interface=wg-vpn list=LAN
/ip/firewall/filter/add chain=input action=accept protocol=udp \
  dst-port=51820 in-interface-list=WAN comment="wg-vpn inbound"

The interface, an address for the tunnel subnet, wg-vpn added to the LAN interface list so the tunnel can reach LAN hosts and the router itself, and exactly one firewall rule to let the handshake in from the WAN. That is the whole server, minus the peers.

The test that fails and shouldn't scare you#

First attempt to bring the tunnel up came from a laptop already on the LAN, pointed at the router's public IP as its endpoint. Zero handshakes. last-handshake never populated. It looks precisely like a broken server.

The server was fine. A host inside the LAN generally cannot reach its own router's public IP — that's hairpin NAT, the router turning a packet around to reach a service on its own WAN address, and plenty of setups don't do it. The tunnel was fine and the test was invalid, because a LAN client hitting the public endpoint is asking the router to do a thing it has no reason to support.

Two valid tests instead of one invalid one. To check the server, point a LAN client at the router's LAN address:

Endpoint = 192.168.88.1:51820

To check the external path — the only one that matters — use a device that is genuinely outside: a phone on mobile data. A completed handshake from there is the only proof that UDP 51820 gets in from the internet; a TCP port check on some other service says nothing about it. Testing a remote-access tunnel from inside the network it terminates on proves nothing either way.

Serving a QR from a box with no screen#

The config needs to get to the phone, and the ergonomic form is a QR code. But this is a headless server — nothing to display a PNG on. So render it and serve it over the LAN, briefly:

mkdir -p /tmp/wg
qrencode -t PNG -o /tmp/wg/friend.png < friend.conf
python3 -m http.server 8099 --directory /tmp/wg &

The served directory holds the PNG and nothing else; friend.conf stays where it was generated, out of reach of the web server.

The firewalld hole to reach it is the important detail:

sudo firewall-cmd --add-port=8099/tcp

No --permanent. A runtime-only rule lives in the running firewall and evaporates on the next firewall-cmd --reload or reboot — it cannot outlive the session and become the stale open port you find in an audit two years later. The page is serving a file that contains a private key; it should be structurally incapable of persisting. Scan it, then tear the whole thing down by hand:

sudo firewall-cmd --remove-port=8099/tcp
kill %1
shred -u friend.conf /tmp/wg/friend.png

kill %1 works because the server was started with & from the same shell; the shred paths are the two files that hold the key.

Which brings us back to the camera#

Here is the part the guides skip. A WireGuard config is text:

[Interface]
PrivateKey = <44 base64 characters that are the entire secret>
Address = 10.8.0.2/32

Encode that as a QR and it is still text — the QR is a picture of the config, private key and all. Point the iOS Camera app at it and iOS does what it does with any text it finds in an image: it surfaces it and offers you actions. One of those actions is a web search. Tap it, and the client's private key leaves the phone and lands in a Google query, logged, forever.

A WireGuard QR must be scanned inside the WireGuard app — Add tunnel → Create from QR code — where the bytes are parsed as a tunnel config and go nowhere else. The Camera app treats the same pixels as a string to be helpful about. Same image, two completely different threat models, and the dangerous one is the default app that opens when you raise the phone.

Because the private key had, however briefly, been one careless tap from Google, I rotated it — new keypair, updated the peer's public key on the router, reissued the config. Rotating a WireGuard key is cheap: one peer entry changes, one client reinstalls. Treating a key that might have leaked as a key that did is cheaper than being wrong about it.

The proof that it works is external by construction#

The confirmation was unambiguous, and it came from the one place a VPN has to work. In /interface/wireguard/peers/print, the phone's peer showed a last-handshake seconds old, and its current-endpoint-address was the phone's mobile-carrier address — a real external IP, not a LAN address, not a hairpin artifact. Initiated from outside the network entirely. That is end-to-end proof: the packet crossed the ISP's mapping, hit the one open UDP port, completed the handshake, and the phone was on the LAN's terms.

There's a coda. The phone, now on the tunnel, tried to reach the Transmission box and got a 403. Its source IP was no longer 192.168.88.x — it was 10.8.0.x, the tunnel address — and the RPC whitelist rejected it on sight. The fix was to add 10.8.0.* to the whitelist, which meant the stop-edit-start dance, because the whitelist isn't settable over RPC. The VPN worked perfectly. It just introduced the phone to the network as a stranger, and the stranger got carded.

Filed under: wireguard, vpn, mikrotik, security, ios

Sources#

  • WireGuard quickstart — key generation and peer configuration.
  • MikroTik: WireGuard — /interface/wireguard setup, peers, and adding the interface to a list so the tunnel reaches the LAN.
  • firewall-cmd(1) — runtime vs --permanent rules; runtime changes are discarded on --reload or restart.
  • qrencode(1) — rendering the config to a scannable PNG on a headless host.