proxmox oidc PKCE support

davidludvigsson

New Member
May 20, 2026
1
0
1

OIDC realm:​


Summary​


The OIDC realm in PVE appears to use the configured client-key (client secret) on every token exchange against the IdP, regardless of whether the auth code being exchanged was obtained via a confidential-client flow (browser → PVE UI redirect URI) or a public-client flow (browser → loopback redirect URI on a CLI tool's local listener).


This means a single OIDC realm cannot simultaneously serve:


  • the PVE web UI, whose registered redirect URI lives on a "Web" platform in the IdP and requires a client secret on token exchange, and
  • a CLI/automation tool that uses the same realm via /access/openid/auth-url + /access/openid/login, with a loopback redirect-url, where the IdP treats that auth code as having come from a public client and rejects token exchanges that include a client secret.

The workaround we've ended up at is two PVE realms backed by two separate IdP app registrations — one confidential, one public — which works but is duplicative and surprising. This post lays out what each side is doing and where the conflict is, in case it's useful as a feature request or design discussion.


Environment​


  • PVE 8.x cluster (issue reproducible across 8.2 and 8.3)
  • IdP: Microsoft Entra ID (Azure AD), but the underlying behaviour is per the OAuth 2.0 / OIDC spec and would presumably reproduce against any compliant IdP that distinguishes confidential and public clients
  • One realm of type openid, issuer-url set to https://login.microsoftonline.com/<tenant>/v2.0

What the web UI does (works with​


Standard Authorization Code flow, confidential client:


  1. User clicks the realm in the PVE login dropdown.
  2. PVE constructs the auth URL with redirect_uri=https://<pve-host>:8006.
  3. Browser → IdP → user authenticates → browser is redirected back to https://<pve-host>:8006/?code=...&state=....
  4. PVE's frontend posts the code + state to /access/openid/login.
  5. PVE backend exchanges the code at the IdP's token endpoint, sending client_id + client_secret (the configured client-key).
  6. IdP returns ID/access tokens. PVE validates and issues a PVE ticket.

The IdP app registration for this case has:


  • A Web platform configured with redirect URI https://<pve-host>:8006.
  • A client secret issued and stored in PVE as client-key.
  • Treated by the IdP as a confidential client — token exchange requires the secret.

This all works correctly. No issue here.


What a CLI tool does (only works with​


We have an internal script that deploys VMs to a dev cluster. Rather than ask users to paste tokens or manage API tokens manually, it reuses the realm's OIDC config by:


  1. Binding a short-lived HTTP listener on 127.0.0.1:<random-port>.
  2. Calling POST /api2/json/access/openid/auth-url with realm=<our-realm> and redirect-url=http://127.0.0.1:<port>/callback. PVE accepts the loopback redirect URI as-is and constructs an IdP auth URL using it.
  3. Opening the browser at the returned URL. User signs in at the IdP.
  4. IdP redirects back to http://127.0.0.1:<port>/callback?code=...&state=... — caught by the local listener.
  5. Script posts the code + state + redirect-url to /api2/json/access/openid/login.
  6. PVE backend tries to exchange the code at the IdP's token endpoint.
  7. IdP rejects the exchange because the auth code was issued under a redirect URI registered on a public-client platform, but PVE is presenting client credentials (the client-key).

The relevant IdP-side detail: loopback redirect URIs like http://127.0.0.1 and http://localhost are only permitted under the IdP's "Mobile and desktop applications" / public-client platform configuration. Once a redirect URI is on a public-client platform, the IdP enforces public-client semantics on the resulting auth code — specifically, the token exchange must not include client credentials, and PKCE is expected.


If client-key is unset in domains.cfg, PVE's token exchange request omits the secret and the IdP accepts the exchange. The CLI flow then completes and returns a PVE ticket.


Removing client-key to make the CLI flow work breaks the UI flow, because the UI's auth code came from a confidential-client redirect URI and the IdP refuses to exchange it without the secret.


Hence the toggle: secret in domains.cfg → UI works, CLI breaks. Secret removed → CLI works, UI breaks.


Why this is surprising​


From the realm's point of view, both flows hit the same two endpoints (/access/openid/auth-url and /access/openid/login) with the same realm name. The only difference is the value of redirect-url supplied by the caller. The IdP cares about that difference and switches between confidential and public client semantics accordingly; PVE does not, and applies the configured client-key uniformly.


A few things contribute to the gotcha:


  • PVE accepts arbitrary redirect-url values from the API caller (including loopback URIs), which strongly suggests the realm is intended to support both UI and external/CLI consumers.
  • The realm config has no field to indicate "this realm is public" vs "this realm is confidential," and client-key is treated as the switch — but only by absence.
  • Documentation around the realm doesn't currently call out that the redirect-url parameter on /access/openid/auth-url must be consistent with the IdP-side platform classification of that URI, which is the actual constraint at play.

Workaround in use​


Two PVE realms, two IdP app registrations:


  • entra-ui realm — client-key set, IdP app has Web platform + secret, used by browser logins.
  • entra-cli realm — client-key unset, IdP app has Mobile and desktop applications platform with loopback redirect URIs, no secret, "Allow public client flows" enabled. Used by the CLI tool by setting PROXMOX_REALM=entra-cli.

This works but means:


  • Two IdP app registrations to maintain, with parallel group/claim mappings.
  • Users appear in PVE as user@entra-ui and user@entra-cli, requiring duplicated ACLs or a shared group with ACLs granted to the group.
  • New automation tools have to know to target the CLI realm specifically.

What would help​


Either of these would close the gap; (1) is the more spec-aligned answer, (2) is the smaller change:


  1. Public-client + PKCE support in the OIDC realm. Add a realm flag (e.g. client-type = confidential | public, or pkce = 1) that, when set, causes PVE to perform a PKCE-protected auth code flow and omit client credentials on token exchange. The same realm could then serve UI and CLI as long as the IdP app registration is configured as a public client with PKCE.
  2. Per-request override. Allow the caller of /access/openid/auth-url and /access/openid/login to opt into a public-client exchange for that specific code, e.g. by sending a public=1 flag alongside redirect-url. PVE would then skip sending the secret for that exchange. UI flows continue to use the confidential exchange by default. This keeps the single-realm/single-app-registration model viable.

Either change would let one realm and one IdP app registration cleanly serve both the UI and CLI/automation use cases.


Reproduction steps​


  1. Configure an OIDC realm against any IdP that distinguishes public and confidential clients, with client-key set.
  2. Register a Web-platform redirect URI for the PVE UI in the IdP. Log in via the UI — works.
  3. From a script, POST /api2/json/access/openid/auth-url with redirect-url=http://127.0.0.1:<port>/callback. Register the loopback URI on a public-client platform in the IdP.
  4. Complete the flow and POST /api2/json/access/openid/login with the resulting code. Observe the token exchange fail at the IdP with an error along the lines of "client_secret is not allowed for public clients" or equivalent.
  5. Unset client-key on the realm. Repeat step 4 — succeeds. Repeat step 2 — fails.

Happy to provide IdP-side error payloads and PVE-side debug logs if useful. Also happy to test patches against our cluster.


Related code​


Relevant call sites in pve-access-control (paths may differ across versions):


  • PVE::API2::OpenId::auth_url — constructs the IdP auth URL from realm config + caller-supplied redirect-url.
  • PVE::API2::OpenId::login — performs the token exchange via PVE::RS::OpenId / proxmox-openid.
  • proxmox-openid (Rust) — the underlying OIDC client; this is where the confidential-client assumption is baked in.

Thanks for reading — happy to clarify anything or move this to the bug tracker if that's a better venue.
 
I'm also just a user of PVE, but thanks for writing this down. I'm currently at the stage of thinking about writing such a script which performs IdP authentication like you described, so the issue you found is helpful to know (should this also apply to my IdP, where I'm currently unsure).

With what I already through I about, I can make some comments to gotchas of yours which might be helpful for further understanding:

PVE accepts arbitrary redirect-url values from the API caller (including loopback URIs), which strongly suggests the realm is intended to support both UI and external/CLI consumers.
I do not think this necessarily suggests that. Rather that feels for me more like as a consequence in that PVE does not know under which URLs / domains it is legitimitely reachable, and in that the Web UI behaves more or less like a public client, as it is mostly JavaScript based & uses the API in the same way as your CLI does. Accepting localhost redirects could be useful in case the Proxmox host itself runs a web browser (which is possible, as you could install a desktop UI beside the PVE services, if you really want to xD). But of course this property helps with implementing authentication for CLI clients.

Documentation around the realm doesn't currently call out that the redirect-url parameter on /access/openid/auth-url must be consistent with the IdP-side platform classification of that URI, which is the actual constraint at play.
While I feel your frustration that the PVE docs did not help you discover that, IMHO opinion after reading much from the specs: the concept "IdP-side platform classification of the redirect URI" is not something I ever did hear before.
From what I understand form the specs, an IdP like Entra should have a property per client stating whether it should be considered a public or a confidential one. That property might be inferred implicitly from other properties like whether the IdP even has credentials it could authenticate the client with. And I think its reasonable for Entra to put further requirements on public clients like enforcing PKCE for those. But the restriction of Entra to block requests for a public client to contain any kind of authorization data sounds weird. Not necessarily out of specs, but Entra could just ignore the credentials like e.g. Keycloak seems to do it (not verified by execution, just by looking at the code).
Therefore to me this feels like something Entra should point out at its documentation, not necessarily Proxmox.

But while writing this, have you already tried out registering a single public client on Entra with your Proxmox HTTPS UI URL & a localhost URL allowed as redirect urls & registering that on Proxmox without giving it a client secret? Because I think this should also work for the UI, at least in my case I see that the UI always uses the same static redirect URI (https://proxmox.example.com). And your post suggests that Proxmox behaves correctly for Entra when not supplying it with a client secret. And from my understanding, even when PVE lacks PKCE support, the security risks should still be limited as long as the redirect URLs are correctly verified against the correct HTTPS URLs & localhost URLs (assuming the attacker model excludes interception of localhost traffic).

Public-client + PKCE support in the OIDC realm
Further, my PVE seems to use PKCE, at least it attaches a challenge to its redirect to my IdP. But I'm currently using Proxmox 9.2, maybe this was added in between?

Please correct me if I misunderstood something from your post. Hope my answer helps you.