CXone OAuth PKCE: 400 Bad Request on token exchange with invalid_grant

Trying to implement the Authorization Code flow with PKCE for a standalone SPA that integrates with CXone. The goal is to avoid storing client secrets in the frontend code. I’m using the standard PKCE challenge/verifier pattern.

Here is the flow:

  1. Generate code_verifier and code_challenge (SHA256, base64url encoded).
  2. Redirect user to https://api.mynicecx.com/oauth2/v1/authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256
  3. User logs in, gets redirected back with ?code=AUTH_CODE
  4. Exchange code for token via POST to /oauth2/v1/token

Step 4 is failing. I get a 400 Bad Request. The response JSON is:

{
 "status": 400,
 "code": "invalid_grant",
 "message": "Invalid authorization code"
}

I’ve checked the following:

  • client_id matches the registered application exactly.
  • redirect_uri matches the one in the CXone console settings (including the trailing slash).
  • The code parameter is passed correctly from the URL query string.
  • The code_verifier sent in the POST body is the exact same string used to generate the challenge.
  • The request is made immediately after receiving the code. No significant time delay.

The POST body looks like this (using application/x-www-form-urlencoded):

grant_type=authorization_code&code=THE_CODE&redirect_uri=MY_URI&client_id=MY_CLIENT_ID&code_verifier=MY_VERIFIER

I’ve tried removing client_id from the body, thinking it might be handled differently, but the error persists. Is there a specific requirement for the token endpoint request format in CXone that differs from the standard OAuth2 spec? Or could the issue be with how the code_challenge is being calculated? The hash function seems correct, but I’m double-checking the base64url encoding (no padding).

Any ideas on what causes an invalid_grant if the code itself is fresh and valid?