Connecting an iPhone app to Proxmox safely
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 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:
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:
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 changesEvery 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, 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.
If you'd rather manage Proxmox from an AI assistant than from your phone, proxmox-mcp covers the same API from the other side. How to build an MCP server explains how it's put together.
Written by Matt Oddie · Markdown version