Building a custom SPA to bypass the standard login widget for a specific client integration. Need to implement the Authorization Code flow with PKCE. The redirect works fine, but the token exchange fails.
Here is the setup:
- Generate code_challenge using SHA256 and base64url encoding.
- Redirect to
https://{{account-id}}.mypurecloud.com/oauth/authorize?response_type=code&client_id={{id}}&redirect_uri={{uri}}&code_challenge={{challenge}}&code_challenge_method=S256
- Get the
code back in the callback.
When I POST to https://{{account-id}}.mypurecloud.com/oauth/token, the response is 400. The error body says:
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or is invalid"
}
I’ve checked the docs. The body of the POST request is:
const params = new URLSearchParams();
params.append('grant_type', 'authorization_code');
params.append('code', authCode);
params.append('redirect_uri', 'http://localhost:3000/callback');
params.append('client_id', 'my-client-id');
params.append('code_verifier', originalVerifier);
The originalVerifier matches the one used to generate the challenge. Timing is within 60 seconds. I tried removing the code_verifier field just to see if it was a format issue, but the error is the same. I suspect the base64url encoding in my JS implementation might be dropping padding or using the wrong character set for the challenge. Does the CXone OAuth endpoint strictly require un-padded base64url for the verifier as well? Or is there a specific header I’m missing in the token request?
Cause: The code_verifier you’re sending in the token request doesn’t match the code_challenge derived from it during the authorization request. This is almost always a base64url encoding mismatch. Standard base64 uses + and /, while PKCE requires base64url which uses - and _ and omits padding =. If your JS library outputs standard base64, the server rejects it because the hash doesn’t match the stored challenge.
Solution: Ensure your verifier is raw random bytes, and the challenge is the SHA-256 hash of that verifier, converted to base64url. Here’s how to do it correctly in JS without external libs that might mess up the encoding:
async function generatePkce() {
// 1. Generate random verifier (32-128 chars)
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, ''); // Remove padding
// 2. Generate SHA-256 hash of verifier
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// 3. Convert hash to base64url
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashB64 = btoa(hashArray.map(b => String.fromCharCode(b)).join(''))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, ''); // Remove padding
return { verifier, challenge: hashB64 };
}
When exchanging the code, send code_verifier exactly as generated. Don’t URL-encode it again if your fetch library already does that, but make sure it’s the raw string. Also check your client_id scope. If you’re hitting api.mypurecloud.com, ensure the app has the correct OAuth scopes enabled in the admin console. I’ve seen this fail silently if the app type isn’t set to “Confidential” for the token exchange step.
The base64url encoding is definitely the usual suspect, but don’t overlook the SHA-256 hashing step itself. The code_verifier must be a random string of 43-128 characters. If you’re generating that in Python, make sure you are using secrets or urandom, not just a simple UUID or timestamp, because the entropy needs to be high enough to pass the length and character checks Genesys Cloud enforces.
Here is a working Python using cryptography and base64 that handles the encoding strictly. This avoids the common pitfall where padding characters (=) slip through or standard base64 characters (+, /) are used.
import secrets
import hashlib
import base64
def generate_pkce_params():
# 1. Generate code_verifier (43-128 chars, unreserved URL characters)
code_verifier = secrets.token_urlsafe(64)
# 2. Hash the verifier with SHA-256
sha256_hash = hashlib.sha256(code_verifier.encode('ascii')).digest()
# 3. Base64url encode the hash (no padding, replace + with -, / with _)
code_challenge = base64.urlsafe_b64encode(sha256_hash).decode('utf-8').rstrip('=')
return {
"code_verifier": code_verifier,
"code_challenge": code_challenge
}
params = generate_pkce_params()
print(f"Verifier: {params['code_verifier']}")
print(f"Challenge: {params['code_challenge']}")
When you make the token exchange request to /oauth/token, you must send the exact code_verifier string in the body. It’s case-sensitive. I’ve seen issues where the frontend JavaScript library truncates the verifier or adds a newline character at the end during copy-paste or logging. Check your network tab in the browser dev tools. Look at the actual POST body sent to the token endpoint. Compare the code_verifier value there against the one generated in the first step. They must match byte-for-byte.
Also, ensure your OAuth client in Genesys Cloud has the Public setting enabled if you are doing this from a browser SPA. Confidential clients require a client secret in the token request, which breaks the PKCE flow for public clients. If the client is set to Confidential, you’ll get a 400 error for missing credentials even if PKCE is perfect.