roundcube/roundcubemail · warning · RuntimeException

OIDC: event has non-empty "nonce"

Error message

OIDC: event has non-empty "nonce"

What it means

Per the OIDC Back-Channel Logout spec, a logout token MUST NOT contain a 'nonce' claim (that would make it replayable as an ID token). The handler rejects any token carrying a nonce.

Solutions

  1. Fix the IDP so it emits a real logout token (no nonce, has 'events' claim) to the back-channel URI.
  2. Confirm the back-channel logout URI is set to the oauth_backchannel action, not the login/token endpoints.
  3. Inspect the incoming JWT payload and remove 'nonce' if generating tokens in a test harness.
  4. Update the IDP version if it has a known bug including nonce in logout tokens.

Example fix

// before (ID token sent as logout token)
{"iss":"https://idp","aud":"rc","sub":"u1","nonce":"abc123"}
// after
{"iss":"https://idp","aud":"rc","sub":"u1","events":{"http://schemas.openid.net/event/backchannel-logout":{}}}
Defensive patterns

Strategy: validation

Validate before calling

$body = json_decode(base64_decode(strtr(explode('.', $jwt)[1], '-_', '+/')), true);
if (isset($body['nonce'])) { /* reject: nonce present, not a logout token */ }

Type guard

function isNonceFree(array $claims): bool { return !array_key_exists('nonce', $claims); }

Try / catch

try { $handler->run(); } catch (\RuntimeException $e) { http_response_code(400); error_log('backchannel: ' . $e->getMessage()); }

Prevention

When it happens

Trigger: run() receives a JWT whose payload includes a 'nonce' key — typically an ID token was sent to the back-channel logout endpoint instead of a proper logout token.

Common situations: Misconfigured IDP wiring the token/ID-token endpoint output into back-channel logout; custom middleware forwarding access tokens; developer testing with an ID token copied from a login flow.

Related errors


AI-assisted analysis of roundcube/roundcubemail@4b54c2acfb (2026-09-14). Data as JSON: /api/errors/323a4fd229c6c6fd. Report an issue: GitHub.

Appendix: source

Thrown at program/actions/login/oauth_backchannel.php:69

                    "iss":"https://....",                                // issuer identifier
                    "aud":"my client id",                                // audience = client id
                    "sub":"82c8f487-df95-4960-972c-4e680c3c72f5",        // subject
                    "sid":"28101815-0017-4ade-a550-e054bde07ded",        // session
                    "events":{"http://schemas.openid.net/event/backchannel-logout":[]}
                }
                */

                // Validation: https://openid.net/specs/openid-connect-backchannel-1_0.html#rfc.section.2.6

                // Note: 'typ' is recommended, not required, so we allow untyped tokens
                if (!empty($event['header']['typ']) && $event['header']['typ'] !== 'logout+jwt') {
                    throw new \RuntimeException('OIDC: Handle only logout events');
                }
                if (!isset($event['sub'])) {
                    throw new \RuntimeException('OIDC: event has no "sub"');
                }
                if (isset($event['nonce'])) {
                    throw new \RuntimeException('OIDC: event has non-empty "nonce"');
                }

                $rcmail->oauth->log_debug('backchannel: logout event received, schedule a revocation for token\'s sub: %s', $event['sub']);
                $rcmail->oauth->schedule_token_revocation($event['sub']);

                http_response_code(200); // 204 works also
                header('Content-Type: application/json; charset=UTF-8');
                header('Cache-Control: no-store');
                echo '{}';
                exit;
            } catch (\Exception $e) {
                rcube::raise_error($e, true);
                $answer['error_description'] = 'Error decoding JWT';
            }
        } else {
            rcube::raise_error(sprintf('oidc backchannel called from %s without any parameter', rcube_utils::remote_addr()), true);
        }

View on GitHub (pinned to 4b54c2acfb)