Subchapter 127.3
concepts/oauth-flows.mdMarkdown22 KBView on GitHub
Zoom supports 4 OAuth 2.0 flows. This guide helps you choose the right one and understand how each works.
Endpoint split to remember:
https://zoom.us/oauth/authorizehttps://zoom.us/oauth/token| Your Scenario | Flow | Grant Type |
|---|---|---|
| Backend automation on your own account | S2S OAuth | account_credentials |
| SaaS app for other Zoom users | User OAuth | authorization_code |
| Device without browser (TV, kiosk, IoT) | Device Flow | urn:ietf:params:oauth:grant-type:device_code |
| Team Chat bot only | Chatbot | client_credentials |
| Type | User Involved? | Zoom Flows |
|---|---|---|
| Two-legged | No (app acts on its own) | S2S OAuth, Chatbot |
| Three-legged | Yes (user authorizes app) | User OAuth, Device Flow |
When to use:
Grant type: account_credentials
Token lifetime:
Credentials required:
┌──────────────┐ ┌──────────────┐
│ Your App │ │ Zoom OAuth │
│ (Backend) │ │ Server │
└──────┬───────┘ └──────┬───────┘
│ │
│ POST /oauth/token │
│ grant_type=account_credentials │
│ account_id={ACCOUNT_ID} │
│ Authorization: Basic {CLIENT_ID:CLIENT_SECRET} │
│──────────────────────────────────────────────────>│
│ │
│ │ Validate
│ │ credentials
│ │
│ { access_token, expires_in, scope } │
│<──────────────────────────────────────────────────│
│ │
│ API Requests with Bearer token │
│ (valid for 1 hour) │
│ │const axios = require('axios');
const qs = require('query-string');
const getToken = async () => {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'account_credentials',
account_id: process.env.ZOOM_ACCOUNT_ID
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data; // { access_token, expires_in, scope, token_type }
};✅ Simple: No redirect URIs, no user interaction ✅ Secure: Credentials stored server-side only ✅ Account-wide: Single token for all account operations ⚠️ No refresh token: Just request a new token when expired (cache with TTL)
When to use:
Grant type: authorization_code
Token lifetime:
Credentials required:
┌────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ User │ │ Your App │ │ Zoom OAuth │ │ Zoom API │
│Browser │ │ (Server) │ │ Server │ │ Server │
└────┬───┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ 1. Click "Add App" │ │ │
│─────────────────────>│ │ │
│ │ │ │
│ 2. Redirect to authorize │ │
│https://zoom.us/oauth/authorize? │ │
│ client_id={ID} │ │
│ redirect_uri={URI} │ │
│ response_type=code │ │
│ state={RANDOM} │ │
│<─────────────────────│ │ │
│ │ │ │
│ 3. User sees "Allow" page │ │
│─────────────────────────────────────────────────>│ │
│ │ │ │
│ 4. User clicks "Allow" │ │
│─────────────────────────────────────────────────>│ │
│ │ │ │
const express = require('express');
const crypto = require('crypto');
app.get('/auth', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state; // Store for verification
const authURL = new URL('https://zoom.us/oauth/authorize');
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('client_id', process.env.ZOOM_CLIENT_ID);
authURL.searchParams.set('redirect_uri', process.env.ZOOM_REDIRECT_URL);
authURL.searchParams.set('state', state);
res.redirect(authURL.toString());
});app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state to prevent CSRF
if (state !== req.session.oauthState) {
return res.status(403).send('Invalid state parameter');
}
try {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.ZOOM_REDIRECT_URL
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, refresh_token } = response.data;
// Store tokens securely (encrypted) per user
await saveUserTokens(req.session.userId, {
access_token,
refresh_token
});
res.send('Authorization successful!');
} catch (error) {
res.status(500).send('Token exchange failed');
}
});✅ User-controlled: Users authorize access to their own account ✅ Per-user tokens: Each user gets their own access/refresh tokens ✅ Refresh support: Tokens can be refreshed while the refresh token remains valid (lifetime varies; ~90 days is common) ⚠️ Redirect URI must match exactly: Including trailing slash, protocol, port ⚠️ State parameter required: Prevent CSRF attacks ⚠️ Authorization code expires in 5 minutes: Exchange immediately
When to use:
Grant type: urn:ietf:params:oauth:grant-type:device_code
Token lifetime:
Credentials required:
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ Device │ │ Zoom OAuth │ │User's Phone│
│ (TV/Kiosk) │ │ Server │ │ / Computer │
└──────┬─────┘ └──────┬───────┘ └─────┬──────┘
│ │ │
│ 1. POST /oauth/devicecode │
│ client_id={CLIENT_ID} │
│───────────────────────>│ │
│ │ │
│ 2. Return device_code, user_code, verification_uri, interval
│ { device_code, user_code, verification_uri, interval }
│<───────────────────────│ │
│ │ │
│ 3. Display to user: │ │
│ "Go to zoom.us/activate" │
│ "Enter code: ABC-DEF" │ │
│ │ │
│ │ 4. User visits URL │
│ │ and enters user_code │
│ │<────────────────────────│
│ │ │
│ │ 5. User clicks "Allow" │
│ │<────────────────────────│
│ │ │
│ 6. Poll for token (every {interval} seconds) │
│ POST /oauth/token │
│ grant_type=urn:ietf:params:oauth:grant-type:device_code
│ device_code={DEVICE_CODE} │
│───────────────────────>│ │
│ │ │
│ 7. Response (repeat until success or timeout) │
│ - authorization_pending (keep polling) │
│ - slow_down (increase interval) │
│ - expired_token (restart flow) │
│ - { access_token, refresh_token } (success!) │
│<───────────────────────│ │const requestDeviceCode = async () => {
const response = await axios.post(
'https://zoom.us/oauth/devicecode',
qs.stringify({
client_id: process.env.ZOOM_CLIENT_ID
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data;
/*
{
device_code: "GmRhmhcxhwAzkoEqiMEg_DnyEysNmsh6JCl-fNkAghaUg",
user_code: "ABC-DEF",
verification_uri: "https://zoom.us/activate",
expires_in: 900, // 15 minutes
interval: 5 // Poll every 5 seconds
}
*/
};const { device_code, user_code, verification_uri, interval } = await requestDeviceCode();
console.log(`\nGo to: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);const pollForToken = async (device_code, interval) => {
const pollInterval = interval * 1000; // Convert to milliseconds
let currentInterval = pollInterval;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: device_code
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Success! Got tokens
resolve(response.data);
} catch (error) {
const errorCode = error.response?.data?.error;
if (errorCode === 'authorization_pending') {
// User hasn't authorized yet, keep polling
setTimeout(poll, currentInterval);
} else if (errorCode === 'slow_down') {
// Zoom wants us to slow down, increase interval by 5s
currentInterval += 5000;
setTimeout(poll, currentInterval);
} else if (errorCode === 'expired_token') {
// Device code expired (15 minutes), restart flow
reject(new Error('Device code expired. Please restart authorization.'));
} else {
// Other error
reject(error);
}
}
};
// Start polling
poll();
});
};✅ No browser required: User authorizes on separate device
✅ Simple user experience: Just enter a short code
✅ Polling-based: Device polls until user authorizes
⚠️ Must enable in app settings: “Use App on Device” feature flag
⚠️ Device code expires in 15 minutes: User must complete authorization quickly
⚠️ Respect polling interval: Returned by /devicecode endpoint (usually 5s)
⚠️ Handle slow_down: Increase interval by 5s when requested
When to use:
imchat:bot scopeGrant type: client_credentials
Token lifetime:
Credentials required:
┌──────────────┐ ┌──────────────┐
│ Chatbot App │ │ Zoom OAuth │
│ (Backend) │ │ Server │
└──────┬───────┘ └──────┬───────┘
│ │
│ POST /oauth/token │
│ grant_type=client_credentials │
│ Authorization: Basic {CLIENT_ID:CLIENT_SECRET} │
│──────────────────────────────────────────────────>│
│ │
│ { access_token, expires_in, scope } │
│<──────────────────────────────────────────────────│
│ │
│ Chatbot API Requests with Bearer token │
│ (valid for 1 hour) │
│ │const getChatbotToken = async () => {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'client_credentials'
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${process.env.ZOOM_CLIENT_ID}:${process.env.ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data; // { access_token, expires_in, scope, token_type }
};✅ Simplest flow: Just request token with credentials
✅ Chatbot-specific: Limited to Team Chat bot operations
⚠️ No refresh token: Request new token when expired
⚠️ Scope limited: Primarily imchat:bot scope
| Feature | S2S OAuth | User OAuth | Device Flow | Chatbot |
|---|---|---|---|---|
| Grant Type | account_credentials | authorization_code | device_code | client_credentials |
| User Interaction | No | Yes (browser) | Yes (separate device) | No |
| Access Token Lifetime | 1 hour | 1 hour | 1 hour | 1 hour |
| Refresh Token | ❌ None | ✅ ~90 days (commonly) | ✅ ~90 days (commonly) | ❌ None |
| Redirect URI | ❌ Not needed | ✅ Required | ❌ Not needed | ❌ Not needed |
| PKCE Support | ❌ N/A | ✅ Optional | ❌ N/A | ❌ N/A |
| State Parameter | ❌ N/A | ✅ Recommended | ❌ N/A | ❌ N/A |
| Account Access | Account-wide | Per-user | Per-user | Account-wide |
| Token Storage | Redis (ephemeral) | Database (persistent) | Database (persistent) | Redis (ephemeral) |
| Use Case | Backend automation | SaaS apps | TV/kiosk apps | Chat bots |
Zoom OAuth follows these RFCs:
RFC 6749: OAuth 2.0 Authorization Framework
RFC 7636: PKCE (Proof Key for Code Exchange)
RFC 8628: Device Authorization Grant
Source