# Connecting an iPhone app to Proxmox safely

Home Lab · 2026-10-19 · 6 min read · by Matt Oddie
Canonical: https://mattoddie.dev/homelab/proxmox-from-an-iphone/

> How Proxly, an iPhone app for Proxmox VE, trusts a self-signed certificate, signs in with tickets or API tokens, and keeps secrets on the phone.

A homelab Proxmox server usually has a self-signed certificate, a root password and a web interface on port 8006. A phone app has to accept that certificate without accepting every certificate, sign in without keeping a password it doesn't need, and stay out of the way. This post is about how [Proxly](https://proxly.mattoddie.dev) does each of those.

Proxly is my iPhone app for Proxmox VE, in beta on TestFlight now. It talks to the same JSON API as the web interface, at `https://<host>:8006/api2/json`, with no agent on the server and no cloud service in between. Every request goes from the phone to the server.

## Trusting a self-signed certificate

Proxmox signs each node's certificate with its own cluster CA, which no phone trusts. The usual shortcut in homelab tools is a switch that turns off certificate checks. That makes the warning go away, but it also means anyone on the path can stand in for your server and collect the password you send it.

Proxly uses trust on first use instead, the same model SSH uses for host keys. On the first connection, the app shows the certificate's SHA-256 fingerprint and asks you to compare it with **Node → System → Certificates** in the web interface. If you accept, the fingerprint is saved with the server. After that the app accepts that exact certificate and nothing else.

The check lives in a `URLSession` delegate. It accepts the server if the leaf certificate matches the pin, or if the system trusts the certificate anyway, which covers anyone who has set up Let's Encrypt through Proxmox's ACME support. Anything else is cancelled, and the rejected certificate is kept so the app can show it to you:

Proxly/Core/Networking/Transport.swift:

```
let info = Self.certificateInfo(for: trust, host: challenge.protectionSpace.host)
if let pinnedFingerprint, let info, info.fingerprint == pinnedFingerprint {
    return (.useCredential, URLCredential(trust: trust))
}
if SecTrustEvaluateWithError(trust, nil) {
    return (.performDefaultHandling, nil)
}
lock.withLock { rejected = info }
return (.cancelAuthenticationChallenge, nil)
```

The fingerprint is a hash of the certificate itself, not its CA. That's deliberate. Pinning the cluster CA would be more convenient, but anyone who could get a certificate from that CA would then pass the check. The cost is that a renewed certificate looks like a stranger. When that happens, Proxly shows the new fingerprint next to the old one in a red warning, rather than the amber one you see on first connection. If you renewed the certificate yourself, you accept it once. If you didn't, something else is going on.

The same delegate refuses all redirects. The Proxmox API never sends one, and refusing them means a response can't send your credentials on to another host.

## Two ways to sign in

Proxmox accepts two kinds of credential, and they behave quite differently.

|  | API token | Password |
| --- | --- | --- |
| Sent on each request | `Authorization: PVEAPIToken=user@realm!name=secret` | `PVEAuthCookie` ticket, plus a CSRF token on changes |
| Lifetime | Until it expires or you revoke it | Two hours, renewable |
| Permissions | Can be limited to less than the user has | Everything the user can do |
| Two-factor | Not involved | TOTP or recovery codes |
| Built-in console | Shows the console's own login | Works straight away |

An API token is the better choice for an app. You create it under **Datacenter → Permissions → API Tokens**, give it only the roles you want the phone to have, and revoke it from the web interface if you lose the phone. A read-only token turns Proxly into a monitoring app that can't break anything.

Passwords still matter, because that's how most people sign in to Proxmox, and the console needs a session. With a password, the app posts to `/access/ticket` and gets back a ticket and a `CSRFPreventionToken`. The ticket goes in a cookie on every request. The CSRF token goes in a header on anything that isn't a `GET`.

Two-factor adds a step. If the account has TOTP turned on, the first response carries `NeedTFA` and a challenge ticket instead of a full one. The app asks for the code and posts again with the challenge and `totp:123456` as the password. A recovery code goes through the same way with a `recovery:` prefix.

## Keeping the session alive

A ticket lasts two hours. Signing in again from scratch would mean asking for a two-factor code every two hours, which is the kind of thing that makes people turn two-factor off. Proxmox has a better way: post the current ticket as the password, and you get a fresh one with no second factor. Proxly renews once a ticket is an hour old, and only falls back to a full sign-in when renewal fails or the ticket has run out:

Proxly/Core/Networking/ProxmoxClient.swift:

```
let age = Date.now.timeIntervalSince(ticket.issued)
if age < 60 * 60 { return }
// Tickets last two hours; renewing with the old ticket avoids a second TFA prompt.
if age < 110 * 60, let renewed = try? await requestTicket(username: ticket.username, password: ticket.ticket), !renewed.needsTFA {
    store(renewed)
    return
}
try await login()
```

Screens load in parallel: a node's page asks for its charts, pending updates and disks at the same time. If each request renewed the ticket on its own, opening one page could ask Proxmox for three tickets. So the renewal runs as one shared task, and any request that arrives while it's running waits for it instead of starting another.

## Where the secrets live

Server names, addresses and pinned fingerprints aren't secret, so they go in the app's ordinary preferences. Passwords, token secrets and current tickets go in the iOS Keychain, with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. That setting means they can only be read while the phone is unlocked, and they never leave the device: not in an iCloud Keychain sync, and not in a backup restored to another phone.

The networking avoids leaving copies elsewhere. Proxly's `URLSession` uses an ephemeral configuration with no cookie store and no response cache, and the app sets the auth cookie and headers itself on each request. Nothing about the session is written to disk except what's in the Keychain.

One catch: Keychain items outlive the app, but preferences don't. If you delete Proxly and install it again, its old secrets are still in the Keychain. So on a fresh install, Proxly clears them. An early build decided "fresh install" by checking for a first-launch flag, and when a beta update added that flag, testers who updated looked like new installs and lost their saved passwords. The fix was to clear the Keychain only when there are no saved servers either. If a secret ever does go missing, the app now says so and asks for it again, rather than sending an empty password and reporting "session expired".

> **Asking before changes.** Every request that changes the server goes through one place in the API client. That's where Face ID sits: in the app's settings you can ask for Face ID before reboots, deletes and other changes, or lock the whole app. Browsing stays quick, and a phone left unlocked on a desk can't shut down a node.

## Reaching the server from outside

None of this is a reason to put port 8006 on the internet. The Proxmox web interface wasn't built to face the internet, and it shouldn't. Proxly works from anywhere your phone can reach the server, so the answer away from home is a VPN: WireGuard, Tailscale or your router's own. If your Proxmox hosts sit on their own VLAN, as they do on my [UniFi lab network](https://mattoddie.dev/homelab/unifi-lab-network/), make sure the VPN's clients are allowed to reach it.

## Try it

Proxly is in beta on TestFlight and coming to the App Store. You don't need a server to try it. **Explore the Demo** on the welcome screen runs a simulated two-node homelab behind the same API, so every screen works. There's more at [proxly.mattoddie.dev](https://proxly.mattoddie.dev).

If you'd rather manage Proxmox from an AI assistant than from your phone, [proxmox-mcp](https://proxmox-mcp.mattoddie.dev) covers the same API from the other side. [How to build an MCP server](https://mattoddie.dev/ai/building-an-mcp-server/) explains how it's put together.
