Authorization Code + PKCE failing with invalid_grant in custom SPAs

We are trying to migrate our custom agent desktop wrapper from the deprecated implicit flow to the Authorization Code flow with PKCE. It’s a single-page application built with React, running entirely in the browser. No backend server involved for the token exchange.

The initial redirect to /oauth2/authorize works fine. We generate the code_verifier and code_challenge using SHA-256. The user authenticates and gets redirected back with the code query parameter.

The problem happens when we hit /oauth2/token to exchange that code. We are getting a 400 Bad Request with invalid_grant. The documentation says this usually means the code expired or was already used, but we are making the request immediately upon load.

Here is how we are constructing the POST body:

const params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('code', codeFromQuery);
params.append('redirect_uri', window.location.origin + '/callback');
params.append('code_verifier', storedCodeVerifier);
params.append('client_id', process.env.REACT_APP_CLIENT_ID);

We are sending this as application/x-www-form-urlencoded. The code_verifier matches the base64url-encoded SHA-256 hash we sent in the code_challenge during the auth request.

Is there something specific about how Genesys Cloud handles the redirect_uri validation in this flow? We noticed if we add a trailing slash to the redirect URI in the token request but not in the authorize request, it fails. But our URIs match exactly.

Also, we are using the standard SDK for the initial login, but doing the manual HTTP POST for the token exchange because the SDK’s Auth module seems to expect a server-side flow or implicit parameters.

Any ideas on why the grant is invalid? We’ve checked the logs and the code is definitely fresh. It’s driving me nuts.