decolua/9router · error

OIDC token exchange failed (${res.status})

Error message

OIDC token exchange failed (${res.status})

What it means

exchangeOidcCode POSTs the authorization code to the IdP token endpoint and throws when the response is non-ok. It prefers the IdP's own error_description/error fields (standard OAuth2 error codes like invalid_grant, invalid_client) and only falls back to the generic `OIDC token exchange failed (<status>)` message when the response body is not JSON or lacks those fields. So this exact generic string appears mainly when the token endpoint returns an error without an OAuth2 JSON body.

Source

Thrown at src/lib/auth/oidc.js:138

    code,
    redirect_uri: redirectUri,
    code_verifier: codeVerifier,
  });

  if (clientSecret) {
    body.set("client_secret", clientSecret);
  }

  const res = await fetch(tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });

  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`;
    throw new Error(message);
  }

  return data;
}

export async function probeOidcClientSecret({
  tokenEndpoint,
  clientId,
  clientSecret,
  redirectUri,
}) {
  if (!clientSecret) {
    return {
      tested: false,
      valid: null,
      message: "No client secret was provided, so secret validation was skipped.",
    };
  }

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Log res.status and the raw response body at the throw site to see the IdP's actual error; prefer the error_description when present.
  2. Verify the redirect_uri sent matches the IdP app registration exactly (scheme, host, port, path) — check BASE_URL/NEXT_PUBLIC_BASE_URL and x-forwarded-proto/host behind your reverse proxy.
  3. Confirm clientId/clientSecret in dashboard settings are current; test them with probeOidcClientSecret (oidc.js) which classifies invalid_client vs benign errors.
  4. Retry the login from scratch — authorization codes are single-use and short-lived; don't re-POST the same code, and check for clock skew (NTP) if using PKCE/nonce validation.

Example fix

// before
const data = await res.json().catch(() => ({}));
if (!res.ok) {
  const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status})`;
  throw new Error(message);
}
// after
const raw = await res.text();
let data = {};
try { data = JSON.parse(raw); } catch {}
if (!res.ok) {
  const message = data?.error_description || data?.error || `OIDC token exchange failed (${res.status}): ${raw.slice(0, 200)}`;
  throw new Error(message);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Probe client credentials and redirect_uri before the real login flow
const probe = await probeOidcClientSecret({ tokenEndpoint, clientId, clientSecret, redirectUri });
if (probe.tested && probe.valid === false) throw new Error(probe.message); // invalid_client

Type guard

function isOAuthTokenResponse(data) {
  return !!data && typeof data === "object" && typeof data.access_token === "string";
}

Try / catch

try {
  const tokens = await exchangeOidcCode({ tokenEndpoint, clientId, clientSecret, code, redirectUri, codeVerifier });
} catch (err) {
  if (err.message === "invalid_grant") {
    // code expired/used — restart the authorization flow with a fresh state+PKCE pair
  } else if (err.message === "invalid_client") {
    // credentials wrong — surface a settings fix hint
  } else {
    throw err;
  }
}

Prevention

When it happens

Trigger: The callback flow calls exchangeOidcCode after the user returns from the IdP and the token endpoint responds non-ok with a non-JSON or JSON-less-of-error body: 400 from a redirect_uri/client_id mismatch without JSON body, 401 invalid_client with empty body, 500 from the IdP, HTML error pages from a proxy/gateway (502/504), or an expired/already-used authorization code (invalid_grant) when the IdP returns no description.

Common situations: redirect_uri configured in the IdP app doesn't exactly match the one sent (BASE_URL/x-forwarded headers mismatch behind a reverse proxy), wrong client_secret or a secret that was rotated, the authorization code was reused after a refresh/retry (codes are single-use) or expired (typically 30-60s), clock skew, or a corporate proxy intercepting the POST and returning an HTML error page.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/99e0ab37de7a6003. Report an issue: GitHub.