PKCE Code Verifier mismatch in CXone OAuth for SPA

Getting a 400 Bad Request on the token exchange step. The error message is generic: invalid_grant.

Trying to implement the Authorization Code flow with PKCE for a single-page app that needs to authenticate users against our CXone org. The goal is to avoid storing client secrets in the frontend bundle.

The initial auth request to https://api.mynice.com/oauth2/v2/authorize works. I get redirected back with the code and state. The code looks valid. State matches.

Here is the payload I am sending to POST https://api.mynice.com/oauth2/v2/token:

{
 "grant_type": "authorization_code",
 "code": "AUTH_CODE_HERE",
 "redirect_uri": "https://myapp.local/callback",
 "client_id": "MY_CLIENT_ID",
 "code_verifier": "VERIFIER_STRING"
}

The code_verifier was generated using the standard base64url encoding of a random byte array. I used the same crypto library to generate the code_challenge for the initial authorize request.

Checked the encoding manually. The challenge sent in the first request matches the verifier sent here when hashed with SHA-256.

Tried swapping the crypto library. Tried generating the verifier with a simpler random string. Same 400 error.

Is there a specific requirement for the code_verifier length or character set in CXone that isn’t in the standard OAuth2 spec? The docs just say “standard PKCE”.

Here is the generating the challenge for the first step:

async function generatePKCE() {
 const verifier = generateRandomString(64);
 const encoder = new TextEncoder();
 const data = encoder.encode(verifier);
 const digest = await crypto.subtle.digest('SHA-256', data);
 const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
 .replace(/\+/g, '-')
 .replace(/\//g, '_')
 .replace(/=/g, '');
 return { verifier, challenge };
}

The verifier string is 43 characters long. The challenge is 43 characters.

Running into a wall here.