Blog ·
Proxmox VNC asks for the same ticket twice
Your noVNC client connects to Proxmox and then hangs on a black screen. The vncproxy ticket opens the tunnel; the VNC server behind it wants that ticket again.
By Provibr
You wire up a VNC console for Proxmox. You request a ticket, you open the WebSocket, the upgrade succeeds, bytes start flowing. And the screen stays black. No error. No close frame. noVNC just sits there.
The tunnel is fine. What you are missing is that Proxmox authenticates you twice, with the same secret, at two different layers, and only the first one is obvious.
What vncproxy gives you
POST /api2/json/nodes/{node}/qemu/{vmid}/vncproxy?websocket=1The response looks roughly like this:
{"data":{
"ticket":"PVEVNC:6...........................",
"port":"5900",
"user":"root@pam",
"upid":"UPID:...",
"cert":"-----BEGIN CERTIFICATE-----\n...",
"password":"6......."
}}Two things about that payload.
First, password is not a separate credential. It is the first 8 characters of ticket. Compare them and you will see it.
Second, that is not a coincidence or an implementation shortcut. The RFB protocol's VNC Authentication scheme (security type 2) caps the password at 8 bytes by construction: the challenge is DES-encrypted with the password as the key, and DES keys are 8 bytes. Proxmox is not inventing a second secret. It is handing you the largest slice of the ticket that RFB security type 2 can physically accept.
So: ticket authorizes the WebSocket upgrade. password (= ticket[0..8]) authenticates the RFB session inside it. Skip the second and you get exactly what we got: a healthy socket carrying a handshake that never completes.
> Measured on Proxmox VE 9.2.4, on both vga: std and a serial0/vncterm console. The behaviour was identical for both.
The handshake, in order
POST /nodes/{node}/qemu/{vmid}/vncproxy?websocket=1 → ticket + password
GET /nodes/{node}/qemu/{vmid}/vncwebsocket?port=…&vncticket=…
Upgrade: websocket
Sec-WebSocket-Protocol: binary
→ 101 Switching Protocols
<-- RFB 003.008\n (server version)
--> RFB 003.008\n (client version)
<-- security types: [2] ← VNC Authentication, not None
<-- 16-byte challenge
--> DES(challenge, key = password)
<-- SecurityResult: OKIf you are handing the socket to noVNC rather than doing this yourself, the fix is to pass the credential in:
new RFB(target, url, { credentials: { password: passwordFromVncproxy } });and also listen for credentialsrequired as a fallback, because that is the event noVNC fires when it reaches the security handshake without a credential in hand. It is the difference between a clear failure and an indefinite black screen.
Three more things that bit us
websocket=1 is not optional in practice. Leave it off and Proxmox hands you a ticket for a raw TCP port in the 5900 range on the node itself. That means a second inbound hole in the customer's network, on a port range that changes per session. With the flag, everything stays on 8006, the port you already have open.
port is sometimes a number and sometimes a string. Depending on the release, vncproxy returns "port":"5900" or "port":5900. A u16 field in your response struct works against one cluster and fails to deserialize against another, for no reason that will be visible in your logs. Parse it leniently.
Your HTTP client's total timeout will kill the console. We run a 30-second deadline on ordinary API calls. On an upgraded connection the response body never ends, so a total timeout is a guaranteed disconnect at the 30-second mark. The console needs its own client with no overall timeout. Pin it to http1_only with only http/1.1 in ALPN, so that a future Proxmox accepting h2 does not silently make the upgrade route disappear.
The console ticket is a credential. It grants keyboard access to a machine. Redact it in log output the same way you would redact an API token, including in whatever your language's debug-formatting derives for you by default.
Yes, an API token can do this
This was the open question that worried us most, because the answer changes the whole design. If vncproxy had required a full user session, the agent would have needed to hold user credentials rather than a scoped token.
It does not. An API token with VM.Console on the target VM can request a vncproxy ticket and open the console. We proved it end to end against a real cluster: a disposable VM, noVNC reaching connected, the SeaBIOS splash rendering in the browser, and Ctrl+Alt+Del actually rebooting the guest through four network hops.
The failure mode when the permission is missing is worth handling explicitly. It is nearly always a permissions problem the operator can fix themselves, so "console did not open" is a wasted error message. Name the privilege, VM.Console, and where it lives in the Proxmox UI.
The black screen that is not this bug
There is a second way to get a permanently black console, and it looks identical from the outside, so it is worth ruling out.
The first thing that arrives on an RFB stream is the version line: the twelve bytes RFB 003.008\n. A WebSocket buffers nothing for a handler that has not been attached yet. If your client opens the socket, then dynamically imports noVNC, then attaches: those first bytes arrived during the import and are gone. noVNC waits for a version line that already came and went, forever, without an error.
The fix is unglamorous: buffer the frames yourself between "socket open" and "viewer attached", then replay them in order once the viewer is live.
We mention it because if you are debugging a black console and you have just fixed the double authentication, this is the thing that will make you think you did not fix it.
Why any of this is hard to find
The double authentication is not in the Proxmox API documentation in a form that connects the two halves. vncproxy documents a password field; nothing tells you it is a slice of the ticket, or that the VNC server behind vncwebsocket will demand it. And the reason it is easy to build a whole console feature without discovering it is that a mock VNC server written for your own test suite will almost certainly offer security type 1 (None), because that is the easy thing to implement.
That is what happened to us. An entire round of testing went green against a stub that never asked for a password. The bug only surfaced against a real hypervisor.
If you are building this, make your test double demand real VNC authentication. It costs an afternoon (note: recent Node releases no longer expose DES through OpenSSL, so you will be writing that part by hand) and it moves the discovery from production to your laptop.
What we did not test
Measured on PVE 9.2.4 only. We have not checked 8.x or earlier, and we have not measured what happens to an open console when the guest is stopped mid-session. The password-is-a-ticket-slice relationship has held across every response we have seen, but we have not read the Proxmox source to confirm it is guaranteed rather than incidental. Treat it as an observation, and read password rather than slicing ticket yourself.
This came out of building Provibr's Proxmox console, where every byte goes browser → gateway → outbound agent → hypervisor and there is no shortcut layer to hide a broken handshake in. Provibr is a server management panel for hosting providers.