I’m hitting a 400 Bad Request with {"errors":[{"code":"invalid_grant","message":"The authorization code has expired or is invalid"}]} when trying to swap the authorization code for an access token in my single-page app. The app is built with plain JavaScript, no heavy frameworks, and I’m implementing the Authorization Code flow with PKCE because we need to support public clients. I’ve been wrestling with the base64url encoding for the code_verifier and code_challenge for the last few hours. It seems like the Genesys Cloud OAuth endpoint is rejecting the verifier, but my local debug logs show they should match. Here’s how I’m generating the challenge in the browser before redirecting to /oauth2/authorize:
async function generatePKCE() {
const random = new Uint8Array(32);
crypto.getRandomValues(random);
const codeVerifier = btoa(String.fromCharCode(...random))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return { codeVerifier, codeChallenge };
}
Then I redirect to https://{{environment}}.mygenesyscloud.com/oauth2/authorize?response_type=code&client_id={{client_id}}&redirect_uri={{redirect_uri}}&code_challenge={{codeChallenge}}&code_challenge_method=S256. After the user authenticates, I get the code back in the URL. I store the codeVerifier in session storage. When I post to /oauth2/token, I send:
{
"grant_type": "authorization_code",
"code": "the_code_from_url",
"redirect_uri": "http://localhost:3000/callback",
"client_id": "my_public_client_id",
"code_verifier": "the_stored_verifier"
}
The error persists. I’ve double-checked that the redirect_uri matches exactly what’s registered in the admin console. I also verified that the code hasn’t expired by doing this quickly after login. Is there something specific about how Genesys Cloud handles the PKCE verification that I’m missing? Maybe the encoding is slightly off? I feel like I’ve followed the RFC 7636 spec to the letter, but something isn’t clicking. The docs mention using the SDK, but I need raw HTTP control here for this specific module. Any ideas on what could be causing the invalid_grant?