PKCE S256 code_verifier mismatch in JS SDK SPA flow

Building a vanilla JS SPA that needs to auth against Genesys Cloud without a backend. I’m implementing the Authorization Code flow with PKCE as per the docs, but hitting a 400 Bad Request on the token exchange step. The error payload says invalid_grant with a generic “Authorization code has expired or is invalid” message. I’ve verified the code is fresh and the redirect URI matches exactly.

Here’s the snippet generating the challenge and sending the request:

const crypto = window.crypto;
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

// ... redirect to /oauth/authorize with code_challenge_method=S256 ...

// Exchange:
const response = await fetch('/oauth/token', {
 method: 'POST',
 body: new URLSearchParams({
 grant_type: 'authorization_code',
 code: authCode,
 code_verifier: codeVerifier, // Using same verifier here
 client_id: clientId,
 redirect_uri: redirectUri
 })
});

The base64url encoding looks correct when I log it. The SDK’s internal auth helper does this automatically, but I need raw control here for a custom UI. Anyone see a subtle mismatch in the S256 conversion that might trip up the GC oauth server?