PKCE verifier mismatch in SPA Auth Code flow

Struggling to understand why the token exchange fails with a 400 error despite correct PKCE parameters.

I am building a GraphQL gateway that wraps Genesys Cloud APIs for a single-page application. The SPA performs the initial authorization code flow with PKCE. The frontend generates the code verifier and challenge correctly. It redirects to the Genesys Cloud authorization endpoint. After user consent, it receives the code and sends it to my Node.js backend via a secure channel. The backend then calls the token endpoint to exchange the code for an access token.

The request fails with 400 Bad Request. The error response is "error": "invalid_grant" with "error_description": "Code verification failed". I am using the @genesyscloud/purecloud-platform-client-v2 SDK for the backend call. Here is the token request logic:

const { token } = await client.getOauthApi().postOauthToken({
 grant_type: 'authorization_code',
 code: req.body.code,
 redirect_uri: 'https://my-app.com/callback',
 code_verifier: req.body.codeVerifier
});

I have verified that the code_verifier sent by the frontend matches the one derived from the code_challenge. The redirect_uri matches exactly. The code is fresh. Is there a specific format requirement for the code_verifier in the SDK that differs from the standard RFC 7636 implementation? Or is the SDK modifying the payload before sending?

3 Likes

check your backend exchange logic. the 400 usually means the code_verifier you’re sending in the POST body doesn’t match the code_challenge generated on the frontend. make sure you’re using SHA-256 with URL-safe base64 encoding. no padding.

also, ensure your Node.js handler is sending the grant_type as authorization_code and not client_credentials. here’s a quick sanity check for the exchange call:

const tokenRes = await axios.post(`${baseUrl}/oauth/token`, new URLSearchParams({
 grant_type: 'authorization_code',
 code: req.body.code,
 redirect_uri: 'http://localhost:3000/callback', // must match exactly
 client_id: process.env.GC_CLIENT_ID,
 code_verifier: req.body.codeVerifier // critical: must match frontend hash
}));

if that looks right, check the CloudWatch logs for the raw request payload. sometimes the URL encoding gets messy when passing between services. i’ve seen the verifier get double-encoded which kills the hash match.

yeah, that base64url padding issue is the silent killer here. i’ve seen it break SCIM syncs just as badly. if you’re generating the verifier on the frontend but hashing it on the backend for verification (or vice versa), make sure the encoding libraries match exactly.

try this quick node snippet to verify your challenge generation matches what the frontend sent. it’s easy to accidentally include = padding or use standard base64 instead of base64url.

const crypto = require('crypto');

function generateCodeChallenge(verifier) {
 // SHA-256 hash
 const hash = crypto.createHash('sha256').update(verifier).digest();
 // Base64url encode (no padding)
 return hash.toString('base64')
 .replace(/\+/g, '-')
 .replace(/\//g, '_')
 .replace(/=/g, '');
}

console.log(generateCodeChallenge('your-verifier-string'));

double check that your frontend is doing the exact same transformation. also, ensure the code hasn’t expired yet. those codes are short-lived. if it’s still failing, dump the raw request body to a log and compare the hex values of the challenge.

2 Likes

you’re missing the URL-safe part of the base64 encoding. that = padding at the end of standard base64 is what breaks the PKCE exchange 90% of the time. genesys cloud expects base64url without padding.

if you’re hashing in node but generating the challenge in the browser, library mismatches are likely. here’s how i verify the challenge locally before sending:

const crypto = require('crypto');
const codeVerifier = '...'; // from frontend

const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64');
const codeChallenge = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(codeChallenge);
  1. Generate the verifier on the frontend.
  2. Hash it with SHA-256.
  3. Encode as base64url (replace + with -, / with _, strip =).

if your backend is re-hashing the verifier, you’re double-hashing. don’t do that. send the raw verifier in the token request body.

also, check your client_id. if it’s wrong, the error message is still generic 400. annoying.

3 Likes

stop hashing the verifier on the backend. that’s the whole point of PKCE. the frontend must generate both the verifier and the challenge. if they’re different libraries, the hash won’t match.

// frontend only
const verifier = crypto.getRandomValues(new Uint8Array(32));
const challenge = await crypto.subtle.digest('SHA-256', verifier);

keep it simple. don’t overcomplicate it.