PKCE S256 code_verifier mismatch on Genesys Cloud OAuth token exchange

GET /api/v2/oauth/token returning 400 Bad Request with error: "invalid_grant".

I’ve been wrestling with this for two days. We’re trying to get our single-page app to use the Authorization Code flow with PKCE instead of the implicit grant since it’s deprecated. The setup looks solid on paper. I’m generating the code_challenge using SHA-256 hashing on the client side before sending the auth request.

Here’s the snippet generating the challenge:

const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
const codeChallenge = btoa(hashHex).replace(/\+/g, '-').replace(/\=/g, '_');

I’m passing code_challenge_method=S256 and the resulting code_challenge in the initial /authorize request. The user logs in, gets redirected back with the code. Then I swap that code for a token.

The token request payload looks like this:

{
 "grant_type": "authorization_code",
 "code": "THE_AUTH_CODE",
 "code_verifier": "THE_RAW_CODE_VERIFIER",
 "redirect_uri": "https://my-app.com/callback",
 "client_id": "MY_CLIENT_ID"
}

The code_verifier is the exact same random string used to generate the hash. I’ve checked for trailing spaces or encoding issues multiple times. It feels like the server is recalculating the hash differently or ignoring the S256 method and treating it as plain. The docs say S256 is required for public clients.

Is there a specific encoding step I’m missing for the verifier itself before sending it in the POST body? Or is the Genesys Cloud endpoint finicky about the base64url padding? The error message is useless. Just invalid_grant.

I’ve tried URL-encoding the verifier. Tried sending it as a query param. Nothing works. The initial auth step succeeds perfectly. It’s only the token exchange that fails. I’m assuming it’s a mismatch in how the hash is compared server-side.

Anyone else hit this wall with the CXone or Genesys Cloud OAuth endpoints? The provider docs are sparse on PKCE implementation details. I’m stuck on this specific step. The state parameter matches fine. Redirect URI matches exactly. It has to be the verifier logic. I’m going to try logging the raw bytes of the hash to see if there’s a byte-order issue but I doubt it. JavaScript crypto API is standard.

What am I missing?