SPA Authorization Code Flow with PKCE: 400 Bad Request on Token Exchange

Quick question about implementing the Authorization Code OAuth flow with PKCE for a single-page application. I am building a custom Microsoft Teams bot that needs to sync user presence with Genesys Cloud. The bot runs as a web app in the Teams client, which effectively acts as a single-page application (SPA) environment. Since SPAs cannot securely store client secrets, I am forced to use the Authorization Code flow with PKCE instead of the standard client credentials grant.

My implementation follows the standard PKCE pattern:

  1. Generate a code verifier and challenge.
  2. Redirect user to /oauth/authorize with the challenge.
  3. Exchange the received authorization code for an access token at /oauth/token.

The redirect works fine, and I receive a valid code parameter in the callback URL. However, when I attempt to exchange this code for a token using the following POST request to https://{{my_domain}}.mypurecloud.com/api/v2/oauth/token, I consistently receive a 400 Bad Request response.

Here is the payload I am sending:

{
 "grant_type": "authorization_code",
 "code": "AUTH_CODE_FROM_REDIRECT",
 "redirect_uri": "https://my-app.com/callback",
 "client_id": "my-client-id",
 "code_verifier": "original_code_verifier_string"
}

The error body returned is:

{
 "message": "Bad Request",
 "errors": [
 "Invalid grant type or invalid code"
 ]
}

I have verified the following:

  • The client_id is correct and registered in the GC admin portal.
  • The redirect_uri matches exactly what was used in the authorization request.
  • The code_verifier is the exact string used to generate the code_challenge (SHA256 hashed and base64url encoded).
  • The code has not expired.

Is there a specific configuration required for the OAuth client in Genesys Cloud to support PKCE for SPAs? Or am I missing a required parameter in the token exchange payload? I am working from Paris (Europe/Paris timezone) and testing against the EU region endpoints.

If I remember correctly, hitting a 400 Bad Request during the token exchange phase in an SPA usually points to a mismatch in the PKCE code verifier or the redirect URI encoding. The Genesys Cloud OAuth server is strict about the code_verifier matching the SHA256 hash of the code_challenge sent in the initial authorization request. Ensure your frontend generates the challenge using the plain text method for simplicity unless you specifically need S256, and double-check that the redirect URI in your token request exactly matches the one registered in your application settings, including any trailing slashes.

The error often stems from how the authorization header is constructed or missing scopes. When exchanging the code for a token, you must send the code, code_verifier, and redirect_uri in the body, but the client authentication must be handled via the client_id in the body or basic auth header depending on your SDK configuration. For a pure SPA, sending the client_id in the body is safer. Also, verify you are requesting the correct scopes like openid, profile, and email alongside any specific Genesys Cloud permissions such as conversation:call:view.

Here is a standard fetch example for the token exchange that avoids common pitfalls:

const response = await fetch('https://api.mypurecloud.com/oauth/token', {
 method: 'POST',
 headers: {
 'Content-Type': 'application/x-www-form-urlencoded'
 },
 body: new URLSearchParams({
 'grant_type': 'authorization_code',
 'code': authorizationCode,
 'redirect_uri': 'https://your-app.com/callback',
 'client_id': 'YOUR_CLIENT_ID',
 'code_verifier': plainTextVerifier // Must match the challenge
 })
});

Make sure plainTextVerifier is the original random string, not the hashed version. If you see a 400, log the exact error message; it often specifies if the code has expired or if the redirect_uri does not match.

Have you tried validating the encoding of your code_verifier before sending it to the token endpoint? The previous advice on PKCE matching is technically correct, but it misses a common SPA implementation trap. URL-unsafe characters in the verifier often cause silent failures during the hash comparison on the server side.

  1. Ensure your verifier uses only A-Z, a-z, 0-9, -, ., _, ~.
  2. Use encodeURIComponent on the verifier when constructing the token request body.
  3. Verify the code_challenge method matches. If you use S256, the challenge must be the Base64Url-encoded SHA256 hash of the verifier.

Genesys Cloud rejects malformed verifiers with a generic 400 error. Check your console logs for the raw string sent in the POST body.

// Correct token exchange payload structure
const params = new URLSearchParams({
 grant_type: 'authorization_code',
 code: authCode,
 redirect_uri: encodeURIComponent(REDIRECT_URI),
 client_id: CLIENT_ID,
 code_verifier: codeVerifier // Must match the original challenge exactly
});

Stop doing this in the browser. Use a serverless proxy with client credentials.

The SPA context is a security liability and adds unnecessary complexity for a bot.


You might want to look at offloading the auth to a backend microservice. Handling PKCE in a Teams web app is a headache you don't need; just use a Python serverless function with client credentials to get the token and pass it to the frontend. It is cleaner and avoids all the browser-based crypto pitfalls.

You need to ensure the code_verifier generation strictly adheres to the URL-safe Base64 encoding without padding. The Angular crypto polyfill often introduces = padding which Genesys Cloud rejects during the token exchange.

Implement this service method to generate the verifier and challenge correctly:

import { sha256 } from 'js-sha256';

generatePkcePair(): { codeVerifier: string; codeChallenge: string } {
 const array = new Uint8Array(32);
 crypto.getRandomValues(array);
 
 // Encode to URL-safe Base64 without padding
 const codeVerifier = btoa(String.fromCharCode(...array))
 .replace(/\+/g, '-')
 .replace(/\//g, '_')
 .replace(/=+$/, '');

 // SHA256 hash and encode similarly
 const hash = sha256.array(codeVerifier);
 const codeChallenge = btoa(String.fromCharCode(...hash))
 .replace(/\+/g, '-')
 .replace(/\//g, '_')
 .replace(/=+$/, '');

 return { codeVerifier, codeChallenge };
}

The redirect URI in your OAuth client configuration must match exactly, including trailing slashes. Any mismatch triggers the 400. Use the codeChallenge in the initial auth request and codeVerifier in the token request.