9.1 KiB
9.1 KiB
Authentication Flows
All Zoom REST API requests require OAuth 2.0 authentication. This guide covers all supported OAuth flows and when to use each.
Complete OAuth implementation guide: See the zoom-oauth skill for full code examples, token storage, and production patterns.
Flow Selection
| Flow | Use Case | User Interaction | Token Lifetime |
|---|---|---|---|
| Server-to-Server OAuth | Backend automation, bots, integrations | None | 1 hour |
| Authorization Code | User-facing web apps | User consent flow | 1 hour (refresh: 15 years) |
| Authorization Code + PKCE | SPAs, mobile apps | User consent flow | 1 hour (refresh: 15 years) |
| Device Code | TV/IoT devices, CLI tools | User enters code on separate device | 1 hour (refresh: 15 years) |
| DEPRECATED — migrate to S2S OAuth |
Server-to-Server OAuth (Recommended for Backend)
No user interaction required. Best for automation, scheduled tasks, and backend services.
Setup
- Go to Zoom App Marketplace → Develop → Build App
- Select Server-to-Server OAuth
- Note: Account ID, Client ID, Client Secret
- Add required scopes (e.g.,
meeting:write:admin,user:read:admin)
Get Access Token
curl -X POST "https://zoom.us/oauth/token" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=account_credentials&account_id=ACCOUNT_ID"
Response
{
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
"token_type": "bearer",
"expires_in": 3600,
"scope": "meeting:read meeting:write user:read",
"api_url": "https://api.zoom.us"
}
Node.js — Token Manager with Auto-Refresh
class ZoomS2SAuth {
constructor(accountId, clientId, clientSecret) {
this.accountId = accountId;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.token = null;
this.tokenExpiry = 0;
}
async getAccessToken() {
// Return cached token if valid (with 60s buffer)
if (this.token && Date.now() < this.tokenExpiry - 60000) {
return this.token;
}
const credentials = Buffer.from(
`${this.clientId}:${this.clientSecret}`
).toString('base64');
const response = await fetch('https://zoom.us/oauth/token', {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: `grant_type=account_credentials&account_id=${this.accountId}`
});
if (!response.ok) {
const err = await response.json();
throw new Error(`Token error: ${err.error} - ${err.reason}`);
}
const data = await response.json();
this.token = data.access_token;
this.tokenExpiry = Date.now() + (data.expires_in * 1000);
return this.token;
}
async request(method, path, body = null) {
const token = await this.getAccessToken();
const response = await fetch(`https://api.zoom.us/v2${path}`, {
method,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(`Zoom API ${response.status}: ${JSON.stringify(err)}`);
}
// Some endpoints return 204 No Content
if (response.status === 204) return null;
return response.json();
}
}
// Usage
const zoom = new ZoomS2SAuth(
process.env.ZOOM_ACCOUNT_ID,
process.env.ZOOM_CLIENT_ID,
process.env.ZOOM_CLIENT_SECRET
);
const users = await zoom.request('GET', '/users?page_size=300');
const meeting = await zoom.request('POST', '/users/user@example.com/meetings', {
topic: 'API Meeting', type: 2, duration: 30
});
Python — Token Manager
import requests
import time
from base64 import b64encode
class ZoomS2SAuth:
def __init__(self, account_id, client_id, client_secret):
self.account_id = account_id
self.client_id = client_id
self.client_secret = client_secret
self.token = None
self.token_expiry = 0
def get_access_token(self):
if self.token and time.time() < self.token_expiry - 60:
return self.token
credentials = b64encode(
f'{self.client_id}:{self.client_secret}'.encode()
).decode()
response = requests.post(
'https://zoom.us/oauth/token',
headers={
'Authorization': f'Basic {credentials}',
'Content-Type': 'application/x-www-form-urlencoded'
},
data=f'grant_type=account_credentials&account_id={self.account_id}'
)
response.raise_for_status()
data = response.json()
self.token = data['access_token']
self.token_expiry = time.time() + data['expires_in']
return self.token
def request(self, method, path, json_data=None):
token = self.get_access_token()
response = requests.request(
method,
f'https://api.zoom.us/v2{path}',
headers={'Authorization': f'Bearer {token}'},
json=json_data
)
response.raise_for_status()
return response.json() if response.content else None
User OAuth (Authorization Code)
For apps that act on behalf of individual Zoom users.
Flow
1. User clicks "Connect to Zoom"
2. Redirect to: https://zoom.us/oauth/authorize?response_type=code&client_id=XXX&redirect_uri=YYY&state=ZZZ
3. User grants permission
4. Zoom redirects to callback: https://yourapp.com/callback?code=AUTH_CODE&state=ZZZ
5. Exchange code for tokens
6. Use access_token for API calls
7. Refresh when expired
Exchange Code for Token
curl -X POST "https://zoom.us/oauth/token" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=AUTH_CODE&redirect_uri=https://yourapp.com/callback"
Refresh Token
curl -X POST "https://zoom.us/oauth/token" \
-H "Authorization: Basic $(echo -n 'CLIENT_ID:CLIENT_SECRET' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&refresh_token=REFRESH_TOKEN"
Important: me Keyword
User OAuth apps must use me instead of userId in API paths:
# CORRECT for user OAuth
GET /v2/users/me/meetings
# WRONG for user OAuth — will return "Invalid access token"
GET /v2/users/abc123/meetings
Common Scopes
| Scope | Description |
|---|---|
user:read |
Read user profile |
user:read:admin |
Read all users (admin) |
user:write:admin |
Manage all users (admin) |
meeting:read |
Read meeting data |
meeting:write |
Create/update meetings |
meeting:write:admin |
Create/update any user's meetings |
recording:read |
Access recordings |
recording:write |
Manage recordings |
webinar:read |
Read webinar data |
webinar:write |
Manage webinars |
report:read:admin |
View reports |
Best practice: Request only the scopes you need. Fewer scopes = less user friction and faster app approval.
Token Storage Best Practices
// DO: Encrypt tokens at rest
const encrypted = encrypt(accessToken, process.env.ENCRYPTION_KEY);
await db.tokens.upsert({ userId, encrypted, expiresAt });
// DO: Use httpOnly secure cookies for web apps
res.cookie('zoom_session', sessionId, {
httpOnly: true, secure: true, sameSite: 'strict', maxAge: 3600000
});
// DON'T: Store tokens in localStorage or log them
localStorage.setItem('zoom_token', token); // INSECURE
console.log('Token:', accessToken); // LEAKS CREDENTIALS
Error Handling
| Error | Cause | Solution |
|---|---|---|
invalid_grant |
Expired/used auth code or refresh token | Restart OAuth flow or re-authenticate |
invalid_client |
Wrong client ID or secret | Verify credentials |
invalid_scope |
Scope not approved for your app | Check app scopes in Marketplace |
access_denied |
User denied permission | Handle gracefully in UI |
try {
const token = await refreshAccessToken(refreshToken);
} catch (error) {
if (error.response?.data?.error === 'invalid_grant') {
// Refresh token revoked or expired — re-authenticate
redirectToOAuthFlow();
}
}
Migration from JWT (Deprecated)
The JWT app type on Zoom Marketplace is deprecated. This does not affect JWT token signatures used elsewhere (e.g., Video SDK).
Steps:
- Create a Server-to-Server OAuth app
- Request the same scopes
- Replace JWT token generation with OAuth token endpoint
- Test all endpoints
- Delete the JWT app
Resources
- OAuth Guide: https://developers.zoom.us/docs/integrations/oauth/
- S2S OAuth: https://developers.zoom.us/docs/internal-apps/s2s-oauth/
- Scopes Reference: https://developers.zoom.us/docs/integrations/oauth-scopes/
- Full OAuth Skill: See zoom-oauth