#1239·Wallos

OIDC login fails with "OIDC token exchange failed" when the browser retries the callback

Author: thorstenhornung1Created Sep 19, 2026Updated Sep 19, 2026

What happens

An OIDC login ends on the plain page

OIDC token exchange failed. Check the Wallos server logs for details.

and the server log shows

[Wallos OIDC] Token exchange failed for <provider host>: HTTP 400: {"error": "invalid_grant", ...}

The login itself succeeded. Signing in again works, so it looks like an intermittent provider problem — it is not.

Measured

On an instance behind a reverse proxy, with Authentik as the provider, over a mobile connection. Times are from the proxy and the provider's own log, with codes and tokens redacted:

05:19:58.665  provider issues the authorization code
              GET /?code=…&state=…   -> 499 after   61 ms   (client went away)
              GET /?code=…&state=…   -> 200 after 3181 ms, 69 bytes
05:20:00.907  POST /application/o/token/  -> 200      (first request)
05:20:01.940  POST /application/o/token/  -> 400 invalid_grant, "Code does not exist"
05:23:06      a new sign-in from the same client succeeds

69 bytes is the length of the die() message above.

So: the browser sent the callback, gave up on it after 61 ms, and sent it again. Both requests carried the same authorization code, which a provider redeems exactly once. Once in 37 hours on that instance — an unstable connection is what triggers it.

Why both requests get that far

includes/checksession.php validates the state and removes it in memory:

php
unset($_SESSION['oidc_state']);
require_once 'includes/oidc/handle_oidc_callback.php';

PHP writes the session at the end of the request, and the sign-in in includes/oidc/oidc_login.php calls session_regenerate_id(true) before that, which deletes the file the removal would have been written to. So a second request carrying the same callback — waiting on the session lock while the first one talks to the provider — is handed the state as it stood before it was consumed, passes the hash_equals check, and redeems the same code again.

PHP-FPM finishes the first request even though the client is gone, so the abandoned one is the one that does the real work.

Why the person is not signed in afterwards

The first request did sign them in — under a new session id, because session_regenerate_id(true) had just run. The response carrying that new cookie went to the connection the browser had already abandoned. The browser keeps sending the id it has, which after the regeneration names nothing at all, so the account is signed in under an id nobody knows and the person is asked to sign in again.

That is the second half of this, and it is why the symptom reads as "login broken" rather than "one request failed".

Suggested fix

Two independent parts:

  1. Persist the consumption before the exchange. session_write_close() right after the state is removed, then session_start() again. A second callback is then handed the state after the consumption, refuses, and the code is never redeemed twice. Small and self-contained; I will open a pull request for this one.

  2. Do not delete a session whose response may not have arrived. Regenerate without deleting and leave the old session as a short-lived pointer to the new one, as the PHP manual describes for session_regenerate_id() on unstable networks. A retry then lands in the session the first attempt established instead of at the login page. This touches the sign-in path more widely, so it is worth deciding separately.

Happy to prepare the second part as well if the shape above is what you would want.