JWT signature verification failing for implicit grant tokens in React

import { jwtDecode } from "jwt-decode";
import { importSPKI, verifyJWT } from "jose";

const validateLocal = async (token) => {
 const { header, payload } = jwtDecode(token, { complete: true });
 
 // fetching keys
 const jwks = await fetch('https://api.mypurecloud.com/oauth/token/.well-known/jwks.json');
 const keys = await jwks.json();
 
 const keyObj = keys.keys.find(k => k.kid === header.kid);
 const publicKey = await importSPKI(keyObj.n, keyObj.e, { algorithm: 'RS256' });
 
 // this throws
 const result = await verifyJWT(token, publicKey, { algorithms: ['RS256'] });
 return result;
};

Getting JsonWebTokenError: invalid signature on the verifyJWT call.
Happening in the React app.
Using implicit grant flow.

The token decodes fine and the payload looks correct.
iss is https://api.mypurecloud.com/oauth/token.
exp is in the future.
kid matches one of the keys returned by the JWKS endpoint.

We’ve got the integration set up in Terraform.

resource "genesyscloud_auth_integration" "internal_tool" {
 name = "React Dashboard"
 integration_type = "oauth2"
 grant_types = ["implicit"]
 redirect_uris = ["https://dev.example.com/callback"]
 
 scopes = [
 "view:agent",
 "view:interaction"
 ]
}

Config is solid.
Token comes back without errors.

If i hit /api/v2/oauth/token/verify with the token, it returns 200.
So the token is valid server-side.
it’s just the client-side signature check that fails.
don’t see the error in the logs.
won’t verify locally.

Maybe the implicit grant uses a different signing key.
Or is there a specific issuer mismatch for EU vs US environments.
We’re in api.mypurecloud.com but the tenant is technically EU hosted.

The alg header is RS256.
Keys are RSA.

Tried swapping the library to react-jwt but same result.
Signature mismatch.

signature mismatch persists. checked the jwks cache too.

you’re hitting the wrong endpoint. use https://login.mypurecloud.com/oauth/token/.well-known/jwks.json instead. the api subdomain doesn’t serve the keys for implicit grants.

3 Likes

Right endpoint, but the approach is still brittle. Fetching the JWKS on every token validation is slow and will cause rate limiting issues under load. Also, implicit grant tokens are short-lived, so the kid can change before your cache expires if you’re not careful.

Here is a safer pattern using jose. It fetches once, caches, and handles key rotation.

import { createRemoteJWKSet, jwtVerify } from 'jose';

// Create a JWKS instance with caching enabled
const jwks = createRemoteJWKSet(
 new URL('https://login.mypurecloud.com/oauth/token/.well-known/jwks.json'),
 { cacheMaxAge: 60 * 60 * 1000 } // Cache for 1 hour, adjust based on your rotation freq
);

export const validateToken = async (token) => {
 try {
 const { payload, protectedHeader } = await jwtVerify(token, jwks, {
 issuer: 'https://api.mypurecloud.com/',
 audience: ['https://api.mypurecloud.com/'],
 // Optional: strict clock tolerance if needed
 });
 return { valid: true, payload };
 } catch (err) {
 console.error('Token validation failed:', err.message);
 return { valid: false, error: err.message };
 }
};

Also check the iss and aud claims. Implicit grants from the login domain often have slightly different issuer claims than authorization code flows depending on how the org is configured. If you’re using Terraform to manage the app, ensure the redirect_uris and client_type are set correctly in the genesyscloud_application resource to match what the front-end is expecting.

The importSPKI path is where the real pain starts. You’re fetching the keys, but the format returned by Genesys Cloud’s JWKS endpoint isn’t raw SPKI. It’s JWK format. If you try to pass that directly into importSPKI, you’ll get a TypeError about invalid PEM encoding.

Here’s the working pattern using jose. It handles the JWK-to-CryptoKey conversion automatically and respects the alg header. Also, caching that JWKS fetch is critical. Hitting the endpoint on every token check will get you rate-limited fast. The keys don’t change that often.

import { jwtVerify, importJWK } from 'jose';

const JWKS_URL = 'https://login.mypurecloud.com/oauth/token/.well-known/jwks.json';

// Simple cache to avoid network thrashing
let cachedKeys = null;
let cacheExpiry = 0;

async function getPublicKey(kid) {
 const now = Date.now();
 
 // Refresh if cache is stale (e.g., every hour)
 if (!cachedKeys || now > cacheExpiry) {
 const response = await fetch(JWKS_URL);
 const data = await response.json();
 cachedKeys = data.keys;
 cacheExpiry = now + 3600000; // 1 hour
 }

 const jwk = cachedKeys.find(k => k.kid === kid);
 if (!jwk) throw new Error(`Key ID ${kid} not found`);
 
 return await importJWK(jwk, jwk.alg);
}

export async function verifyToken(token) {
 try {
 // Decode header to get kid
 const decoded = jwtDecode(token, { complete: true });
 const publicKey = await getPublicKey(decoded.header.kid);
 
 // Verify signature and check expiration
 const { payload } = await jwtVerify(token, publicKey);
 return payload;
 } catch (err) {
 console.error('JWT verification failed:', err);
 return null;
 }
}

Make sure you’re using login.mypurecloud.com for the JWKS. The api subdomain doesn’t serve these keys for implicit grants. Also, double-check the alg in the token header. If it’s not RS256, the import step will fail.

Spot on. The JWK conversion was the missing piece. Switching to createRemoteJWKSet fixed the TypeError instantly. No more manual key parsing. Works like a charm in SvelteKit server routes. Thanks for the snippet.