Zoom OAuth 2.0 Explained for Developers: JWT vs OAuth, ZAK, and OBF Tokens
Three weeks into building a Zoom meeting bot, you've probably collected five different kinds of Zoom credentials without meaning to: an OAuth token, a client secret, a Meeting SDK JWT you generate yourself, something called a ZAK, and now, since March 2026, something called an OBF token you'd never heard of before. None of them work interchangeably, and Zoom's own docs rarely explain why you need more than one.
Zoom doesn't have an authentication system. It has at least six of them, and they don't overlap the way you'd expect. An OAuth 2.0 access token that works fine against the REST API will get you nowhere in the Meeting SDK. A ZAK token looks like a JWT, is called a JWT internally, but isn't the JWT you generate to authenticate the SDK. And the OBF token didn't exist a couple of years ago; it's now required for a large class of integrations, including meeting bots like ours.
None of this is arbitrary, even though it feels that way the first time you hit it. Each layer exists because a previous, looser layer turned out to be a security or scoping problem, and Zoom narrowed it. This post walks through every layer, what each one is actually for, and, since MeetStream bots join Zoom meetings all day, how this plays out in a real production system.
The core confusion: "authenticating" means different things
Before the individual pieces, it helps to separate three completely different questions Zoom's credentials answer:
- Who is calling the REST API, and as whom? (OAuth 2.0, Server-to-Server OAuth)
- Is this client allowed to open a live meeting session via the SDK, and as whom? (Meeting SDK JWT, ZAK, OBF)
- Did this webhook event really come from Zoom? (Webhook secret token)
These three questions use unrelated credential types, issued from unrelated places, with wildly different lifetimes: as little as five minutes for a ZAK, one hour for an OAuth token, up to ninety rolling days for a refresh token, indefinite for a webhook secret. That mismatch is the root of most "which token do I need here" confusion.
Layer 1: Zoom OAuth 2.0
This is the layer most developers meet first, because it's the closest thing to plain OAuth anywhere else. You register a General app in the Zoom Marketplace, one of three app types (alongside Meeting SDK and Server-to-Server OAuth) available since Zoom unified its app creation flow on September 14, 2024. Pick whether it's user-managed or admin-managed, then run the standard Authorization Code flow:
https://zoom.us/oauth/authorize?response_type=code&client_id=ZOOM_CLIENT_ID&redirect_uri=REDIRECT_URI
The user signs in, approves your requested scopes, and Zoom redirects back with a code you exchange for an access token:
curl -X POST https://zoom.us/oauth/token \
-H "Authorization: Basic BASE64_ENCODED_CLIENT_ID_AND_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=YOUR_REDIRECT_URI"
The resulting access token represents that specific user and expires in an hour. Its refresh token rotates too: each refresh returns a new one, and Zoom expects you to use that latest token next time. Refresh tokens expire after 90 days of disuse, or sooner if the user changes their password, uninstalls your app, or otherwise revokes access, at which point they have to reconnect.
This is the layer behind "read this person's meetings," "schedule a meeting as this user," "list this user's recordings": anything scoped to one Zoom account, through the REST API.
Which OAuth scopes does a meeting bot actually need?
This is the part Zoom's own scope picker doesn't explain well. For a typical bot, you're usually requesting some combination of:
user:read:user: identifies which Zoom user authorized your appuser:read(or granularuser_zak:read): needed for a ZAKuser:read:token(or granularuser:read:token:master): needed for an OBF-eligible tokenmeeting:readormeeting:write: for meeting scheduling through the REST API, though these are classic scopes; new apps should use the granular equivalents instead
You don't need all of these for every integration. A bot that only ever joins meetings hosted inside your own account can often get by with far less than one that needs to join meetings in accounts it doesn't own. Zoom moved from classic to granular scopes in March 2024, so double-check the scope picker in the Marketplace before shipping to production.
Layer 2: Zoom server-to-server OAuth, a quick tutorial
If there's no human to redirect through a consent screen (say, a backend job syncing your own account's data), Server-to-Server, or S2S, OAuth is the right tool. It's a machine-to-machine credential exchange:
POST https://zoom.us/oauth/token?grant_type=account_credentials&account_id=YOUR_ACCOUNT_ID
Authorization: Basic BASE64(client_id:client_secret)
{
"access_token": "<JWT_TOKEN>",
"token_type": "bearer",
"expires_in": 3600,
"scope": "user:read:user:admin"
}
There's no redirect, no refresh token, and no consent screen. You just mint a fresh hour-long token whenever you need one. S2S OAuth apps are created through the same /marketplace/apps endpoint as Meeting SDK and General apps, just with app_type: "s2s_oauth". This is also the grant type developers had to migrate to after Zoom retired JWT apps.
Layer 3: Zoom JWT vs OAuth, why this isn't really a choice anymore
If you're asking whether to use Zoom's JWT app type or OAuth today: JWT isn't on the table. Older code or blog posts referencing a Zoom "JWT app" describe a fossil, one that used to handle the same machine-to-machine job S2S OAuth now covers. Zoom stopped allowing new JWT apps on September 8, 2023, in favor of S2S OAuth, and dropped JWT from the options entirely when the unified build flow launched in September 2024. An old JWT app still running needs to move to Server-to-Server OAuth.
It's worth mentioning anyway because of the pattern it sets: Zoom periodically retires an authentication layer in favor of a narrower, more auditable replacement. That pattern repeats twice more further down this list.
Layer 4: Zoom Meeting SDK JWT authorization, a different token entirely
Here's where most of the confusion actually happens: the Meeting SDK does not use your OAuth access token. It uses a self-signed JWT you generate yourself, with your Meeting SDK app's Client ID and Client Secret (until recently called SDK Key and SDK Secret; Zoom is retiring that naming, with migration enforced as of June 27, 2026):
const KJUR = require("jsrsasign");
const iat = Math.round(new Date().getTime() / 1000) - 30;
const exp = iat + 60 * 60 * 2;
const oHeader = { alg: "HS256", typ: "JWT" };
const oPayload = {
appKey: process.env.ZOOM_CLIENT_ID,
mn: "ZOOM_MEETING_NUMBER",
role: 0,
iat,
exp,
tokenExp: exp,
};
const MEETING_SDK_JWT = KJUR.jws.JWS.sign(
"HS256",
JSON.stringify(oHeader),
JSON.stringify(oPayload),
process.env.ZOOM_CLIENT_SECRET,
);
This JWT proves "this SDK session belongs to an app I control." It authorizes the SDK client to open a session for a given meeting number, nothing more. It has no idea who the user is, which is what the next two layers are for.
Layer 5: ZAK tokens, joining as a specific person
A ZAK (Zoom Access Key) represents a person. It lets your Meeting SDK client start or join a meeting as an already-authenticated Zoom user, without another login inside your embedded SDK session. You fetch it with the user's own OAuth access token, and how long it lasts depends on which endpoint you call:
GET /users/me/zak
Authorization: Bearer <user's OAuth access token>
That shortcut endpoint is convenient, but the token it returns expires five minutes after issuance. For more control, use the general token endpoint instead:
GET /users/me/token?type=zak
Authorization: Bearer <user's OAuth access token>
{ "token": "eyJ0eXAiOiJKV1Qi..." }
Tokens from this endpoint last two hours for a regular user, ninety days for an API-created (custCreate) user, and can extend to a custom TTL of up to one year. Either way, fetch a ZAK right before you need it rather than caching one. This is the classic "host it for me" pattern: your app stands in for a real logged-in user, like a dashboard that starts a user's own meeting for them.
Layer 6: Zoom On Behalf Of (OBF) token, explained
This is the layer where things actually changed recently. Zoom's own docs put it plainly: a ZAK token represents a person, used when the app joins on behalf of an authenticated user. An OBF token represents the app itself, used when it joins as an automated participant, such as a recording or note-taking tool. That "represents the app" framing is Zoom's simplification: minting one still requires a real OAuth-authorized user who granted your app the user:read:token scope, though the token attributes the joining participant to your app, not to that person directly.
Read that phrase again: "an automated participant, such as a recording or note-taking tool" is close to a literal description of what a meeting bot is. Zoom built OBF for exactly this category. Since March 2, 2026, an OBF token has been required whenever a Meeting SDK app, joining as an automated participant, opens a meeting hosted by an account outside the app owner's own account (minimum supported SDK version 5.17.5). This doesn't retire ZAK everywhere: a real user opening a meeting with their own ZAK is still valid. What changed is the bot case, an app showing up as a third party in somebody else's meeting, which is exactly MeetStream's scenario. Apps joining across external accounts also need Zoom's app review process first.
How MeetStream implements this
We support both ZAK and OBF joining internally, but OBF is the recommended path given the March 2026 requirement and the one we expose to customers. The flow comes down to a single Zoom OAuth app, with one stored connection per end-user:
- Register one Zoom Marketplace OAuth app, with
user:read:useranduser:read:tokenscopes. - Each end-user runs a one-time OAuth consent flow (
GET /api/v1/zoom/oauth/authorize-url), and your backend exchanges the resulting code viaPOST /api/v1/zoom/oauth/connections, which returns theirzoom_user_id. - When creating a bot for that end-user's meeting, you pass their stored
zoom_user_idback:
{
"meeting_link": "https://zoom.us/j/123456789?pwd=...",
"bot_name": "MeetStream Bot",
"zoom": {
"use_zoom_obf": true,
"zoom_oauth_connection_user_id": "<end-user's zoom_user_id>"
}
}
MeetStream mints and injects the OBF token behind the scenes, so you never handle raw Zoom token material yourself. The important part architecturally: the credential belongs to your app's connection to that end-user, not to the meeting. One stored OAuth connection covers every future meeting that end-user is ever in.
Layer 7: Zoom's webhook secret tokens run the other direction
Every layer so far answers "can I act on Zoom." Webhook verification answers the opposite question: "did this request that just hit my server actually come from Zoom." Zoom signs each webhook payload with a per-app secret token, sent as the x-zm-signature header:
const message = `v0:${request.headers["x-zm-request-timestamp"]}:${JSON.stringify(request.body)}`;
const hashForVerify = crypto
.createHmac("sha256", ZOOM_WEBHOOK_SECRET_TOKEN)
.update(message)
.digest("hex");
const signature = `v0=${hashForVerify}`;
if (request.headers["x-zm-signature"] === signature) {
// Verified: this came from Zoom
}
There's no expiry, no refresh, no user context here, just a static shared secret and an HMAC comparison. It's the simplest layer because it's solving the simplest problem.
Cheat sheet: "I want to..."
| I want to... | Use | Represents | Typical lifetime |
|---|---|---|---|
| Read/manage a user's meetings via REST API | OAuth 2.0 (General app) | A person | 1 hr access / rolling 90-day refresh |
| Call the REST API for my own account, no user involved | Server-to-Server OAuth | My account | 1 hr |
| Initialize a Meeting SDK client | Meeting SDK JWT (Client ID/Secret) | My app | Up to 48 hrs |
| Start/join a meeting as a specific logged-in user | ZAK token | A person | 5 min to 2 hrs, endpoint-dependent |
| Join a meeting in an external account as an automated bot | OBF token | My app | Short-lived; required for bot joins since Mar 2, 2026 |
| Verify an inbound webhook is genuinely from Zoom |
Webhook secret token
(x-zm-signature)
|
N/A, verification only | Static |
Why Zoom keeps adding layers
It's tempting to read all of this as accidental sprawl. I don't think it is. Every transition here is a retirement, not an addition bolted onto a working system. Zoom retired JWT apps in favor of scoped S2S OAuth. It's retiring SDK Key and Secret in favor of the same Client ID and Secret used everywhere else. And it retired ZAK-only joins for automated participants in favor of mandatory OBF, because "represents a person" was a bad model for a bot that was never impersonating anyone.
Each narrowing follows the same logic: scope the credential as tightly as possible to what it can do (API calls, SDK session, or webhook verification), who it acts as (a person, an app, or no one), and how long it stays valid. A leaked five-minute ZAK is a much smaller incident than a leaked long-lived API key. A leaked OBF token can join a meeting as your app, but never as a specific person. Zoom is trading developer convenience for a shrinking blast radius as the platform matures.
A few habits are worth adopting no matter which layer you're using: store client secrets in a secrets manager, not in code or a committed .env file; rotate credentials after any suspected exposure; treat refresh tokens with the same care as passwords; and never cache a ZAK past the request you fetched it for.
FAQ
- What's the difference between Zoom JWT and OAuth 2.0?
JWT apps were Zoom's original machine-to-machine credential type, retired on September 8, 2023, in favor of Server-to-Server OAuth. OAuth 2.0 (the General app type) is for acting as a specific user; Server-to-Server OAuth is what replaced JWT for acting as your own account, with no user in the loop.
- How does Zoom Server-to-Server OAuth work?
You send a POST request to https://zoom.us/oauth/token with grant_type=account_credentials and your account_id, authenticated with a Basic header built from your client ID and secret. Zoom returns an hour-long access token. There's no redirect, no consent screen, and no refresh token; you just request a new one when the old one expires.
- What Zoom OAuth scopes do I need to join a meeting as a bot?
It depends on how your bot joins. For a ZAK-based join, request user:read (or granular user_zak:read). For an OBF-based join, request user:read:token (or granular user:read:token:master). Most integrations also need user:read:user to identify which end-user authorized the connection.
- Is Zoom JWT authentication still supported?
No. Zoom stopped issuing new JWT apps on September 8, 2023. Any code still generating a JWT-app-style token needs to migrate to Server-to-Server OAuth.
- What's the easiest way to authenticate with the Zoom API for a meeting bot?
Building it yourself means Server-to-Server OAuth for account-level calls plus a per-end-user OAuth connection for OBF-based joins. A hosted meeting bot API like MeetStream's handles that token minting and refresh for you instead.
Sources: Zoom's developer docs and blog: OAuth guide, Meeting SDK auth, OBF FAQ, OBF transition post, OAuth scopes, SDK Key migration, unified build flow, and webhook verification.
