PKCE code_verifier mismatch on NICE CXone OAuth token exchange

Getting a 400 Bad Request from /as/token when trying to exchange the authorization code for an access token. The error response is clear enough:

{
 "error": "invalid_grant",
 "error_description": "Code verification failed"
}

I’m building a single-page app that talks directly to the CXone API without a backend server. I followed the standard Authorization Code flow with PKCE. The initial redirect to /as/authorization works fine. I get the code and state back in the query params. The code looks valid, not expired.

Here is the POST body I’m sending to /as/token:

const tokenPayload = new URLSearchParams();
tokenPayload.append('grant_type', 'authorization_code');
tokenPayload.append('code', authCode);
tokenPayload.append('redirect_uri', 'http://localhost:3000/callback');
tokenPayload.append('client_id', 'my-app-client-id');
tokenPayload.append('code_verifier', generatedCodeVerifier);

fetch('/as/token', {
 method: 'POST',
 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
 body: tokenPayload
});

The issue seems to be with the code_verifier. I generate it in JS using crypto.getRandomValues. I then hash it with SHA-256 and Base64URL encode it to get the code_challenge for the initial auth request. I’m logging both values. They look correct. The verifier is a random string. The challenge is the hashed version.

I tried debugging by logging the raw bytes before encoding. The hash matches what I expect from a Node.js script I wrote to test the logic. But the CXone API rejects it every time.

Is there a specific encoding requirement for the Base64URL step? I’m removing padding = characters. Maybe that’s the problem. Or is the CXone OAuth endpoint strict about the character set? The docs say standard PKCE, but something is off. I’ve checked the redirect_uri matches exactly. Case sensitive too.

Any ideas on why the verification fails?