Update from JWT to SDK authentication getting invalid signature [Web SDK]

We heard that JWT app-types will be deprecated so we’re trying to update our authentication for Zoom API/Webhook and Web SDK (from JWT to SDK authentication). The API/Webhook works fine but the Web SDK has an error.

We’re getting an “Invalid Signature” error in the Web SDK after updating. I’ve double-checked everything and we seem to be doing everything correctly.

Here’s the flow:

  1. Backend server creates a meeting via Zoom API using Server-to-Server app-type credentials. (this is working)
  2. Using the meeting ID generated, backend generates a signature using Zoom SDK app-type credentials. We followed the documentation very carefully, and even validated the generated signature using https://jwt.io/. The signature is correct.
  3. We send the meeting ID, meeting password, the SDK key, and signature to the frontend that is using Zoom Web SDK to initiate the Zoom Client to join the meeting.
  4. Zoom client will load but will return an error prompt saying “Invalid Signature”.

Here’s the exact error message that Zoom Web SDK is showing:
{method: “join”, status: false, result: “Invalid signature.”, errorMessage: undefined, errorCode: 3712}

I’ve looked at the forum to see if they have the same problem. I found this thread: https://devforum.zoom.us/t/invalid-signature-using-web-sdk/46614

The Zoom staff in that thread says that the Web SDK should not use SDK app-type credentials and should just JWT credentials. Is this still the case? I thought JWT will be deprecated by next year?

2 Likes

Hi @yanflex , we are also facing this exact issue. Have followed the documentation to a tee. What’s interesting is that when i use the node backend method of generating the signature, it works every time. On our production servers though, we write java servlets as our endpoints for which to generate signature. Signature is same as node app, but fails with the error message you’ve shown about 30% of the time and is successful the remainder of the time. Will open my own ticket about it and let you know if i get any resolution!

Hi, @yanflex and @mattmcd ,

Thank you both for posting your pain points with generating the JWT SDK token. To clarify, you should be using the SDK Key and Secret from the Meeting SDK app. That aside, I am more than happy to help troubleshoot. If possible, can you share the snippet of code you are using to generate the JWT SDK token?

Meeting SDK App (JWT SDK )

JWT app type migration guide (@mattmcd )

When I am troubleshooting invalid SDK issues, what I normally do is generate tokens with our node sample app. Then manually enter that SDK token in the meeting SDK. This allows me to compare to working token against the invalid one. What I found is that I mixed up the placement of the SDK KEY and Select in the JWT payload: either the time was not set to EPOCH time.

Another gotcha is Zoom uses EPOCH time to determine the start and end/expiration date of the token. By default, the generator uses

  • current time as start time and
  • +48 hours later as end / expiry time

So be sure that you are using EPOCH to determine the time as JWT.IO will still validate the generated signature regardless of the time used.

For reference, here is an example Python Script to generate JWT SDK token:

import jwt
import time
import datetime
import os


iat = float(int(time.time()))
exp = float((datetime.datetime.today() + datetime.timedelta(days=2)).strftime("%s"))
tokenExp = float((datetime.datetime.today() + datetime.timedelta(hours=10)).strftime("%s"))

key= os.environ['ZOOM_SDK_KEY']
secret= os.environ['ZOOM_SDK_SECRET']


payload = {
   'appKey': key,
    'iat': iat,
    'exp': exp,
    'tokenExp': tokenExp
  }

encoded = jwt.encode(payload, secret, algorithm='HS256')
print(encoded)

Zoom Meeting SDK Sample Signature Node.js

Hey Donte,

Sure thing, I actually went and created a ticket of my own after posting here as my issue is a bit different in that i’m only getting the signature invalid error about 3/10 times I reload the page - it’s really random and makes no sense. In other words, 7/10 times i reload the page and attempt to join the webinar, it WORKS. And there’s no difference in the signatures being passed each time as they’re all being generated from the same source.

Here’s my open ticket: #15610152

In short - i’ve completed all of the steps to upgrade the SDK to latest (now using SDK App type creds with SDK key and secret leveraged in the new signature generation style. One odd observation is that when running the app locally (we use a node server when working locally to handle the signature generation), it works 100% of the time, as in i’m able to enter the meeting with no signature invalid errors. However, in production environments, we use Java servlets to handle the signature generation, so implementation is a bit different. This is the scenario where we start getting only about 70% successful joins and 30% of attempts give “signature invalid”.

Here’s the node signature generation code:

require("dotenv").config();
const express = require("express");
const bodyParser = require("body-parser");
const KJUR = require("jsrsasign");
const cors = require("cors");

const app = express();
const port = process.env.PORT || 4000;

app.use(bodyParser.json(), cors());
app.options("*", cors());

app.post("/", (req, res) => {
  const iat = Math.round((new Date().getTime() - 30000) / 1000);
  const exp = iat + 60 * 60 * 2;
  const oHeader = { alg: "HS256", typ: "JWT" };

  const oPayload = {
    sdkKey: process.env.ZOOM_SDK_API_KEY, // Our SDK app "SDK Key"
    mn: req.body.meetingNumber,
    role: req.body.role,
    iat,
    exp,
    appKey: process.env.ZOOM_SDK_API_KEY,
    tokenExp: iat + 60 * 60 * 2,
  };

  console.log(oPayload);

  const sHeader = JSON.stringify(oHeader);
  const sPayload = JSON.stringify(oPayload);
  const sdkJWT = KJUR.jws.JWS.sign("HS256", sHeader, sPayload, process.env.ZOOM_SDK_API_SECRET);  // Our SDK app "SDK Secret"

  res.json({
    sdkKey: process.env.ZOOM_SDK_API_KEY,
    signature: sdkJWT,
  });
});

app.listen(port, () =>
  console.log(`Zoom Web Meeting SDK Sample Signature Node.js on port ${port}!`)
);

…and here’s the Java implementation:

package com.dropbox.cms.dmep.servlets;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Date;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.inject.Inject;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletResponse;

import com.day.cq.search.QueryBuilder;
import com.dropbox.aem.common.services.DropboxLinkHelper;
import com.dropbox.cms.dmep.service.WebinarConfigService;
import com.adobe.granite.crypto.CryptoSupport;

import org.apache.commons.lang.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.servlets.SlingSafeMethodsServlet;
import org.json.JSONException;
import org.json.JSONObject;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Component(
        service = Servlet.class,
        property = {
                "sling.servlet.extensions=" + WebinarServlet.EXTENSION,
                "sling.servlet.paths=" + WebinarServlet.ENDPOINT,
                "sling.servlet.methods=get"
        })
public class WebinarServlet extends SlingSafeMethodsServlet {

    @Inject
    private WebinarConfigService webinarConfigService;

    private static final Logger LOGGER = LoggerFactory.getLogger(WebinarServlet.class);

    public static final String ENDPOINT = "/bin/dropbox/dmep/webinar";
    public static final String EXTENSION = "json";
    private static final String JWT_HEADER = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
    private static final int EXPIRY_DAYS = 90;
    private String signature = StringUtils.EMPTY;
    private String sdkSecret = StringUtils.EMPTY;
    private String sdkKey = StringUtils.EMPTY;
    private String meetingNumber = StringUtils.EMPTY;
    private String role = StringUtils.EMPTY;
    private String encodedHeader = StringUtils.EMPTY;
    private String encodedPayload = StringUtils.EMPTY;
    private int iat = 0;
    private int exp = 0;

    private QueryBuilder queryBuilder;

    private DropboxLinkHelper linkService;

    @Reference
    public void bindWebinarConfigService(WebinarConfigService webinarConfigService) {
        this.webinarConfigService = webinarConfigService;
    }

    public void unbindWebinarConfigService(WebinarConfigService webinarConfigService) {
        this.webinarConfigService = webinarConfigService;
    }

    @Reference
    private CryptoSupport cryptoSupport;

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws ServletException, IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        try {
            response.setStatus(HttpServletResponse.SC_OK);
            sdkKey = webinarConfigService.getSdkKey();
            if (this.cryptoSupport.isProtected(sdkKey)) {
                sdkKey = this.cryptoSupport.unprotect(sdkKey);
            }
            sdkSecret = webinarConfigService.getSdkSecret();
            if (this.cryptoSupport.isProtected(sdkSecret)) {
                sdkSecret = this.cryptoSupport.unprotect(sdkSecret);
            }
            meetingNumber = request.getParameter("meetingNumber");
            role = request.getParameter("role");

            encodedHeader = encode(JWT_HEADER.getBytes());
            if (sdkKey != null && meetingNumber != null && role != null) {
                encodedPayload = encode(generatePayload(sdkKey,meetingNumber,role).getBytes());
            }
            if (sdkSecret != null) {
                signature = hmacSha256(encodedHeader + "." + encodedPayload,sdkSecret);
            }
            if (!encodedPayload.isEmpty() && !signature.isEmpty()) {
                signature = encodedHeader + "." + encodedPayload + "." + signature;
            }

            response.setContentType("application/json");
            response.setCharacterEncoding("UTF-8");

            PrintWriter out = response.getWriter();
            out.print("{\"signature\": \"" + signature
                    + "\"," + "\"sdkKey\": \"" + sdkKey + "\"}");
            out.flush();

        } catch (Exception e) {
            response.getWriter().write(e.getMessage());
            LOGGER.error(e.getMessage(), e);
            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
        }
    }

    /**
     * Generates hmacSha256 signature.
     * CMS-6253 : For zoom sdk update provided
     * proper Json Web Token signature 
     * @return String
     */
    private String hmacSha256(String data, String secret) {
        try {
            byte[] hash = secret.getBytes(StandardCharsets.UTF_8);
            Mac sha256Hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(hash, "HmacSHA256");
            sha256Hmac.init(secretKey);
            byte[] signedBytes = sha256Hmac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return encode(signedBytes);
        } catch (NoSuchAlgorithmException | InvalidKeyException ex) {
            LOGGER.error("Exception while generating hmacSha256 signature {}", ex.getMessage());
            return null;
        }
    }
    /**
     * Generates payload for jwt token using sdkkey,meeting number and role.
     *
     * @return String
     */
    public String generatePayload(String sdkKey,
            String meetingNumber,String role) {
        iat = Math.round((new Date().getTime() - 30000) / 1000);
        exp = iat + 60 * 60 * 2;
        JSONObject jwtPayload = new JSONObject();
        try {
            jwtPayload.put("appKey", sdkKey);
            jwtPayload.put("sdkKey", sdkKey);
            jwtPayload.put("mn", meetingNumber);
            jwtPayload.put("role", Integer.parseInt(role));
            jwtPayload.put("iat", iat);
            jwtPayload.put("exp", exp);
            jwtPayload.put("tokenExp", exp);
            return jwtPayload.toString();
        } catch (JSONException e) {
            LOGGER.error("Exception while generating payload {}", e.getMessage());
            return null;
        }
    }
    
    private static String encode(byte[] bytes) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }
    
    @Reference
    public void bindQueryBuilder(QueryBuilder queryBuilder) {
        this.queryBuilder = queryBuilder;
    }

    @Reference
    public void bindLinkService(DropboxLinkHelper linkService) {
        this.linkService = linkService;
    }
}

Again, we’ve verified from both endpoints that the resulting signature is the exact same, yet we get this “signature invalid” error on ZmMtg.join on the front end ONLY when using the Java servlet. Very odd and leading me to believe it’s got to have something to do with the backend implementation (there were no docs on this, so we had to improv). But implementation shouldn’t matter given we’ve verified signatures from both node and java servlet are exact same structure. We’ve even cross-checked the iat and exp timestamps to see if they were off somehow…nope! Let me know if there’s anything else i can provide!

1 Like

Thank you for the additional information! Sounds like you are on the right track. Based on the details, there could various reasons for the behavior you are seeing (i.e backend, network, java servlet vs node ). Before diving in, can you share a screenshot of the entire error message you are seeing?

Okay, I understand that the script is working, just not %100 of the time in production. In this scenario, are you seeing this only when making requests one after the other? Have you tried to space the page reloads out to see if that resolves the issue ?

Does this only happen when reloading your page ? Does this occur with a specific browser or across all web browsers?

Since the local and production implementation work, it seems something else may be going on. To rule out the Java servlet implementation, can you test to see if the same behavior happens in production with the node JS implementation? I should note, you can easily deploy our sample app to Heroku to test to see if the same behavior occurs:

Can you share more details about your production environment? Is your production under a corporate VPN or firewall that could be interfering with the connection? Are you able to check the network logs? If so, did you observe any high latency or high packet loss ?

Hi Donte, sure

On the UI, we see the error modal pop up saying “Joining meeting timeout. Signature is invalid.”, and in console we print the error which says:
{
“method”: “join”,
“status”: false,
“result”: “Invalid signature.”,
“errorMessage”: “Signature is invalid.”,
“errorCode”: 3712
}

I left an important detail out of my last message which was that this doesn’t just happen in prod environments, but in local. Whether running locally or on prod, behavior is the same, but just to re-clarify - when running this app locally, the following occurs.

  1. The node backend returns signature and works 100% of the time
  2. The java servlet returns signature (literally identical to the node signature) and works about 70% of the time. The other 30% of the time we get the error i specified above.

Just to be sure I wasn’t crazy, i set up the front-end code to request both node and servlet signatures simultaneously upon attempting to join. This way, I could log them in the console and the timestamps received should be pretty much exactly the same +/- a cpl hundred milliseconds. I side-by-sided the decoded signatures (using jwt.io) and they both are exactly the same. So at this point, i think we can rule out signature authenticity as the reason for this, as both are valid, it’s just that the one from the servlet source is somehow randomly corrupt (despite being authentic). Network speed is also ruled out since both are running locally and are returning at the same exact time +/- milliseconds. Can you think of any other reasons for this? Is there a way for us to understand what causes a signature to be deemed “invalid”?

Sorry for the delayed response, @mattmcd. It looks like our support team is working on your ticket. In the meantime, another developer reported a similar problem and was resolve their issue. Can you check to see if the same scenario is happening on your end? Here is the post for your reference.

This was it! We were doing some various testing by changing our implementation style ever so slightly. Removing the rounding solved it for us :partying_face:

1 Like

Awesome, @mattmcd !Glad to hear that resolved the problem and thank you so much for sharing this update – happy coding!