In-client OAuth + PKCE: Always getting invalid_grant (“Invalid authorization code”)

Hi everyone — I’m running into a very specific issue with in-client OAuth (PKCE) that only occurs when I (the admin) add the app for a user in my account.

In the scenario when the user adds the app for themselves (or I as the admin add it for myself), Zoom redirects them through my auth/callback URL in the browser, I exchange the code normally, and everything works.

But when I (the admin) add the app for a user in my account

The user opens the app inside the Zoom client → auth/callback is never hit → I must authorize them via in-client OAuth using:

zoomSdk.authorize({ state, codeChallenge })

…which fires onAuthorized and gives me an authorization code. I try to exchange the authorization for access/refresh tokens, and I get a response of “Invalid authorization code”.


My front-end generates a PKCE pair with this helper:

const VERIFIER_LENGTH = 64;
const STATE_LENGTH = 32;
const CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";

function randomString(length) {
  const array = new Uint8Array(length);
  window.crypto.getRandomValues(array);

  let result = "";
  for (let i = 0; i < array.length; i++) {
    result += CHARSET[array[i] % CHARSET.length];
  }
  return result;
}

async function sha256(input) {
  const encoder = new TextEncoder();
  const data = encoder.encode(input);
  const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
  return new Uint8Array(hashBuffer);
}

function base64UrlEncode(bytes) {
  let binary = "";
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export async function createPkcePair() {
  const codeVerifier = randomString(VERIFIER_LENGTH);
  const hash = await sha256(codeVerifier);
  const codeChallenge = base64UrlEncode(hash);
  const state = randomString(STATE_LENGTH);
  return { state, codeVerifier, codeChallenge };
}

Flow:

  1. Front-end POSTs { state, codeVerifier, uid } to my /pkce-init route, which saves these values to DB.

  2. Front-end calls zoomSdk.authorize({ state, codeChallenge }).

  3. Front-end listens to zoomSdk.onAuthorized(event):

    • receives: code, state, redirectUri, result, timestamp
  4. Front-end POSTs { code, state, redirectUri } to /token-exchange route.

  5. Backend:

    • Looks up codeVerifier from DB using state

    • Calls Zoom:

POST https://zoom.us/oauth/token
grant_type=authorization_code
code=<from onAuthorized>
code_verifier=<from DB>
redirect_uri=<from onAuthorized>
Authorization: Basic <base64(devClientId:devClientSecret)>

I am testing all of this in the Development environment and using the Development client ID / secret. I have the redirect_uri, as it appears from onAuthorized response, listed in my OAuth Allow List.

What I have already read and tried

I’ve read more than a dozen posts about this same issue, and tried the steps that those developers attempted, or what was suggested by Zoom associates.

And I have already verified:

:check_mark: PKCE pairing (codeVerifier ↔ codeChallenge)
:check_mark: redirectUri matching
:check_mark: OAuth Allow List includes the Home URL
:check_mark: Development client ID/secret are used
:check_mark: Authorization code only used once
:check_mark: App is installed for the user and permissions accepted
:check_mark: No duplicate onAuthorized listeners
:check_mark: Curl test uses a fresh untouched code

Despite all this, in-client authorization codes always return invalid_grant.


Can you suggest what I may be doing wrong?

Thank you.