
Zoom Oauth
- 1.4k installs
- 23.1k repo stars
- Updated July 28, 2026
- anthropics/knowledge-work-plugins
zoom-oauth is an agent skill for reference skill for zoom authentication. use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging zoom.
About
The zoom-oauth skill is designed for reference skill for Zoom authentication. Use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging Zoom. Zoom OAuth Background reference for Zoom auth and token lifecycle behavior. Prefer setup-zoom-oauth first, then use this skill for the exact flow, scope, and error details. Invoke when the user asks about zoom oauth or related SKILL.md workflows.
- 5-Minute Runbook - Preflight checks before deep debugging.
- OAuth Flows - Which flow to use and how each works.
- Token Lifecycle - Expiration, refresh, and revocation.
- Production Examples - Redis caching, MySQL storage, auto-refresh.
- Troubleshooting - Error codes 4700-4741.
Zoom Oauth by the numbers
- 1,414 all-time installs (skills.sh)
- +81 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #223 of 2,742 Automation & Workflows skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
zoom-oauth capabilities & compatibility
- Capabilities
- 5 minute runbook preflight checks before deep · oauth flows which flow to use and how each wor · token lifecycle expiration, refresh, and revoc · production examples redis caching, mysql stora
What zoom-oauth says it does
Reference skill for Zoom authentication. Use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging Zoom OAuth f
Reference skill for Zoom authentication. Use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or de
npx skills add https://github.com/anthropics/knowledge-work-plugins --skill zoom-oauthAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.4k |
|---|---|
| repo stars | ★ 23.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | anthropics/knowledge-work-plugins ↗ |
How do I reference skill for zoom authentication. use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging zoom?
Reference skill for Zoom authentication. Use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging Zoom.
Who is it for?
Developers using zoom oauth workflows documented in SKILL.md.
Skip if: Skip when the task falls outside zoom-oauth scope or needs a different stack.
When should I use this skill?
User asks about zoom oauth or related SKILL.md workflows.
What you get
Completed zoom-oauth workflow with documented commands, files, and expected deliverables.
- OAuth flow selection
- endpoint configuration
- grant-type implementation plan
By the numbers
- Covers 4 distinct Zoom OAuth 2.0 flows with explicit grant types
- Uses authorization endpoint https://zoom.us/oauth/authorize and token endpoint https://zoom.us/oauth/token
Files
Zoom OAuth
Background reference for Zoom auth and token lifecycle behavior. Prefer setup-zoom-oauth first, then use this skill for the exact flow, scope, and error details.
Zoom OAuth
Authentication and authorization for Zoom APIs.
📖 Complete Documentation
For comprehensive guides, production patterns, and troubleshooting, see Integrated Index section below.
Quick navigation:
- [5-Minute Runbook](RUNBOOK.md) - Preflight checks before deep debugging
- [OAuth Flows](concepts/oauth-flows.md) - Which flow to use and how each works
- [Token Lifecycle](concepts/token-lifecycle.md) - Expiration, refresh, and revocation
- [Production Examples](examples/s2s-oauth-redis.md) - Redis caching, MySQL storage, auto-refresh
- [Troubleshooting](troubleshooting/common-errors.md) - Error codes 4700-4741
Prerequisites
- Zoom app created in Marketplace
- Client ID and Client Secret
- For S2S OAuth: Account ID
Four Authorization Use Cases
| Use Case | App Type | Grant Type | Industry Name |
|---|---|---|---|
| Account Authorization | Server-to-Server | account_credentials | Client Credentials Grant, M2M, Two-legged OAuth |
| User Authorization | General | authorization_code | Authorization Code Grant, Three-legged OAuth |
| Device Authorization | General | urn:ietf:params:oauth:grant-type:device_code | Device Authorization Grant (RFC 8628) |
| Client Authorization | General | client_credentials | Client Credentials Grant (chatbot-scoped) |
Industry Terminology
| Term | Meaning |
|---|---|
| Two-legged OAuth | No user involved (client ↔ server) |
| Three-legged OAuth | User involved (user ↔ client ↔ server) |
| M2M | Machine-to-Machine (backend services) |
| Public client | Can't keep secrets (mobile, SPA) → use PKCE |
| Confidential client | Can keep secrets (backend servers) |
| PKCE | Proof Key for Code Exchange (RFC 7636), pronounced "pixy" |
Which Flow Should I Use?
┌─────────────────────┐
│ What are you │
│ building? │
└──────────┬──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Backend │ │ App for other │ │ Chatbot only │
│ automation │ │ users/accounts │ │ (Team Chat) │
│ (your account) │ │ │ │ │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ │ ▼
┌─────────────────┐ │ ┌─────────────────┐
│ ACCOUNT │ │ │ CLIENT │
│ (S2S OAuth) │ │ │ (Chatbot) │
└─────────────────┘ │ └─────────────────┘
│
▼
┌─────────────────────┐
│ Does device have │
│ a browser? │
└──────────┬──────────┘
│
┌───────────────┴───────────────┐
│ NO YES│
▼ ▼
┌─────────────────────────┐ ┌─────────────────┐
│ DEVICE │ │ USER │
│ (Device Flow) │ │ (Auth Code) │
│ │ │ │
│ Examples: │ │ + PKCE if │
│ • Smart TV │ │ public client │
│ • Meeting SDK device │ │ │
└─────────────────────────┘ └─────────────────┘---
Account Authorization (Server-to-Server OAuth)
For backend automation without user interaction.
Request Access Token
POST https://zoom.us/oauth/token?grant_type=account_credentials&account_id={ACCOUNT_ID}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}Response
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 3600,
"scope": "user:read:user:admin",
"api_url": "https://api.zoom.us"
}Refresh
Access tokens expire after 1 hour. No separate refresh flow - just request a new token.
---
User Authorization (Authorization Code Flow)
For apps that act on behalf of users.
Step 1: Redirect User to Authorize
https://zoom.us/oauth/authorize?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}Use https://zoom.us/oauth/authorize for consent, but https://zoom.us/oauth/token for token exchange.
Optional Parameters:
| Parameter | Description |
|---|---|
state | CSRF protection, maintains state through flow |
code_challenge | For PKCE (see below) |
code_challenge_method | S256 or plain (default: plain) |
Step 2: User Authorizes
- User signs in and grants permission
- Redirects to
redirect_uriwith authorization code:
https://example.com/?code={AUTHORIZATION_CODE}Step 3: Exchange Code for Token
POST https://zoom.us/oauth/token?grant_type=authorization_code&code={CODE}&redirect_uri={REDIRECT_URI}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}With PKCE: Add code_verifier parameter.
Response
{
"access_token": "eyJ...",
"token_type": "bearer",
"refresh_token": "eyJ...",
"expires_in": 3600,
"scope": "user:read:user",
"api_url": "https://api.zoom.us"
}Refresh Token
POST https://zoom.us/oauth/token?grant_type=refresh_token&refresh_token={REFRESH_TOKEN}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}- Access tokens expire after 1 hour
- Refresh token lifetime can vary; ~90 days is common for some user-based flows. Treat it as configuration/behavior that can change and rely on runtime errors + re-auth fallback.
- Always use the latest refresh token for the next request
- If refresh token expires, redirect user to authorization URL to restart flow
User-Level vs Account-Level Apps
| Type | Who Can Authorize | Scope Access |
|---|---|---|
| User-level | Any individual user | Scoped to themselves |
| Account-level | User with admin permissions | Account-wide access (admin scopes) |
---
Device Authorization (Device Flow)
For devices without browsers (e.g., Meeting SDK apps).
Prerequisites
Enable "Use App on Device" in: Features > Embed > Enable Meeting SDK
Step 1: Request Device Code
POST https://zoom.us/oauth/devicecode?client_id={CLIENT_ID}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}Response
{
"device_code": "DEVICE_CODE",
"user_code": "abcd1234",
"verification_uri": "https://zoom.us/oauth_device",
"verification_uri_complete": "https://zoom.us/oauth/device/complete/{CODE}",
"expires_in": 900,
"interval": 5
}Step 2: User Authorization
Direct user to:
verification_uriand displayuser_codefor manual entry, ORverification_uri_complete(user code prefilled)
User signs in and allows the app.
Step 3: Poll for Token
Poll at the interval (5 seconds) until user authorizes:
POST https://zoom.us/oauth/token?grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code={DEVICE_CODE}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}Response
{
"access_token": "eyJ...",
"token_type": "bearer",
"refresh_token": "eyJ...",
"expires_in": 3599,
"scope": "user:read:user user:read:token",
"api_url": "https://api.zoom.us"
}Polling Responses
| Response | Meaning | Action |
|---|---|---|
| Token returned | User authorized | Store tokens, done |
error: authorization_pending | User hasn't authorized yet | Keep polling at interval |
error: slow_down | Polling too fast | Increase interval by 5 seconds |
error: expired_token | Device code expired (15 min) | Restart flow from Step 1 |
error: access_denied | User denied authorization | Handle denial, don't retry |
Polling Implementation
async function pollForToken(deviceCode, interval) {
while (true) {
await sleep(interval * 1000);
try {
const response = await axios.post(
`https://zoom.us/oauth/token?grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code=${deviceCode}`,
null,
{ headers: { 'Authorization': `Basic ${credentials}` } }
);
return response.data; // Success - got tokens
} catch (error) {
const err = error.response?.data?.error;
if (err === 'authorization_pending') continue;
if (err === 'slow_down') { interval += 5; continue; }
throw error; // expired_token or access_denied
}
}
}Refresh
Same as User Authorization. If refresh token expires, restart device flow from Step 1.
---
Client Authorization (Chatbot)
For chatbot message operations only.
Request Token
POST https://zoom.us/oauth/token?grant_type=client_credentials
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}Response
{
"access_token": "eyJ...",
"token_type": "bearer",
"expires_in": 3600,
"scope": "imchat:bot",
"api_url": "https://api.zoom.us"
}Refresh
Tokens expire after 1 hour. No refresh flow - just request a new token.
---
Using Access Tokens
Call API
GET https://api.zoom.us/v2/users/me
Headers:
Authorization: Bearer {ACCESS_TOKEN}Me Context
Replace userID with me to target the token's associated user:
| Endpoint | Methods |
|---|---|
/v2/users/me | GET, PATCH |
/v2/users/me/token | GET |
/v2/users/me/meetings | GET, POST |
---
Revoke Access Token
Works for all authorization types.
POST https://zoom.us/oauth/revoke?token={ACCESS_TOKEN}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}Response
{
"status": "success"
}---
PKCE (Proof Key for Code Exchange)
For public clients that can't securely store secrets (mobile apps, SPAs, desktop apps).
When to Use PKCE
| Client Type | Use PKCE? | Why |
|---|---|---|
| Mobile app | Yes | Can't securely store client secret |
| Single Page App (SPA) | Yes | JavaScript is visible to users |
| Desktop app | Yes | Binary can be decompiled |
| Meeting SDK (client-side) | Yes | Runs on user's device |
| Backend server | Optional | Can keep secrets, but PKCE adds security |
How PKCE Works
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │ │ Zoom │ │ Zoom │
│ App │ │ Auth │ │ Token │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ 1. Generate code_verifier (random) │ │
│ 2. Create code_challenge = SHA256(verifier) │
│ │ │
│ ─────── /authorize + code_challenge ──► │ │
│ │ │
│ ◄────── authorization_code ──────────── │ │
│ │ │
│ ─────────────── /token + code_verifier ─┼────────────────────────────► │
│ │ │
│ │ Verify: SHA256(verifier) │
│ │ == challenge │
│ │ │
│ ◄───────────────────────────────────────┼─────── access_token ──────── │
│ │ │Implementation (Node.js)
const crypto = require('crypto');
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
const pkce = generatePKCE();
const authUrl = `https://zoom.us/oauth/authorize?` +
`response_type=code&` +
`client_id=${CLIENT_ID}&` +
`redirect_uri=${REDIRECT_URI}&` +
`code_challenge=${pkce.challenge}&` +
`code_challenge_method=S256`;
// Store pkce.verifier in session for callbackToken Exchange with PKCE
POST https://zoom.us/oauth/token?grant_type=authorization_code&code={CODE}&redirect_uri={REDIRECT_URI}&code_verifier={VERIFIER}
Headers:
Authorization: Basic {Base64(ClientID:ClientSecret)}---
Deauthorization
When a user removes your app, Zoom sends a webhook to your Deauthorization Notification Endpoint URL.
Webhook Event
{
"event": "app_deauthorized",
"event_ts": 1740439732278,
"payload": {
"account_id": "ACCOUNT_ID",
"user_id": "USER_ID",
"signature": "SIGNATURE",
"deauthorization_time": "2019-06-17T13:52:28.632Z",
"client_id": "CLIENT_ID"
}
}Requirements
- Delete all associated user data after receiving this event
- Verify webhook signature (use secret token, verification token deprecated Oct 2023)
- Only public apps receive deauthorization webhooks (not private/dev apps)
---
Pre-Approval Flow
Some Zoom accounts require Marketplace admin pre-approval before users can authorize apps.
- Users can request pre-approval from their admin
- Account-level apps (admin scopes) require appropriate role permissions
---
Active Apps Notifier (AAN)
In-meeting feature showing apps with real-time access to content.
- Displays icon + tooltip with app info, content type being accessed, approving account
- Supported: Zoom client 5.6.7+, Meeting SDK 5.9.0+
---
OAuth Scopes
Scope Types
| Type | Description | For |
|---|---|---|
| Classic scopes | Legacy scopes (user, admin, master levels) | Existing apps |
| Granular scopes | New fine-grained scopes with optional support | New apps |
Classic Scopes
For previously-created apps. Three levels:
- User-level: Access to individual user's data
- Admin-level: Account-wide access, requires admin role
- Master-level: For master-sub account setups, requires account owner
Full list: https://developers.zoom.us/docs/integrations/oauth-scopes/
Granular Scopes
For new apps. Format: <service>:<action>:<data_claim>:<access>
| Component | Values |
|---|---|
| service | meeting, webinar, user, recording, etc. |
| action | read, write, update, delete |
| data_claim | Data category (e.g., participants, settings) |
| access | empty (user), admin, master |
Example: meeting:read:list_meetings:admin
Full list: https://developers.zoom.us/docs/integrations/oauth-scopes-granular/
Optional Scopes
Granular scopes can be marked as optional - users choose whether to grant them.
Basic authorization (uses build flow defaults):
https://zoom.us/oauth/authorize?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}Advanced authorization (custom scopes per request):
https://zoom.us/oauth/authorize?client_id={CLIENT_ID}&response_type=code&redirect_uri={REDIRECT_URI}&scope={required_scopes}&optional_scope={optional_scopes}Include previously granted scopes:
https://zoom.us/oauth/authorize?...&include_granted_scopes&scope={additional_scopes}Migrating Classic to Granular
1. Manage > select app > edit 2. Scope page > Development tab > click Migrate 3. Review auto-assigned granular scopes, remove unnecessary, mark optional 4. Test 5. Production tab > click Migrate
Notes:
- No review needed if only migrating or reducing scopes
- Existing user tokens continue with classic scope values until re-authorization
- New users get granular scopes after migration
---
Common Error Codes
| Code | Message | Solution |
|---|---|---|
| 4700 | Token cannot be empty | Check Authorization header has valid token |
| 4702/4704 | Invalid client | Verify Client ID and Client Secret |
| 4705 | Grant type not supported | Use: account_credentials, authorization_code, urn:ietf:params:oauth:grant-type:device_code, or client_credentials |
| 4706 | Client ID or secret missing | Add credentials to header or request params |
| 4709 | Redirect URI mismatch | Ensure redirect_uri matches app configuration exactly (including trailing slash) |
| 4711 | Refresh token invalid | Token scopes don't match client scopes |
| 4717 | App has been disabled | Contact Zoom support |
| 4733 | Code is expired | Authorization codes expire in 5 minutes - restart flow |
| 4734 | Invalid authorization code | Regenerate authorization code |
| 4735 | Owner of token does not exist | User was removed from account - re-authorize |
| 4741 | Token has been revoked | Use the most recent token from latest authorization |
See references/oauth-errors.md for complete error list.
---
Quick Reference
| Flow | Grant Type | Token Expiry | Refresh |
|---|---|---|---|
| Account (S2S) | account_credentials | 1 hour | Request new token |
| User | authorization_code | 1 hour | Use refresh_token (90 day expiry) |
| Device | urn:ietf:params:oauth:grant-type:device_code | 1 hour | Use refresh_token (90 day expiry) |
| Client (Chatbot) | client_credentials | 1 hour | Request new token |
---
Demo Guidance
If you build an OAuth demo app, document its runtime base URL in that demo project's own README or .env.example, not in this shared skill.
Resources
- OAuth docs: https://developers.zoom.us/docs/integrations/oauth/
- S2S OAuth docs: https://developers.zoom.us/docs/internal-apps/s2s-oauth/
- PKCE blog: https://developers.zoom.us/blog/pcke-oauth-with-postman-rest-api/
- Classic scopes: https://developers.zoom.us/docs/integrations/oauth-scopes/
- Granular scopes: https://developers.zoom.us/docs/integrations/oauth-scopes-granular/
---
Integrated Index
_This section was migrated from SKILL.md._
Quick Start Path
If you're new to Zoom OAuth, follow this order:
1. Run preflight checks first → RUNBOOK.md
2. Choose your OAuth flow → concepts/oauth-flows.md
- 4 flows: S2S (backend), User (SaaS), Device (no browser), Chatbot
- Decision matrix: Which flow fits your use case?
3. Understand token lifecycle → concepts/token-lifecycle.md
- CRITICAL: How tokens expire, refresh, and revoke
- Common pitfalls: refresh token rotation
4. Implement your flow → Jump to examples:
- Backend automation → examples/s2s-oauth-redis.md
- SaaS app → examples/user-oauth-mysql.md
- Mobile/SPA → examples/pkce-implementation.md
- Device (TV/kiosk) → examples/device-flow.md
5. Fix redirect URI issues → troubleshooting/redirect-uri-issues.md
- Most common OAuth error: Redirect URI mismatch
6. Implement token refresh → examples/token-refresh.md
- Automatic middleware pattern
- Handle refresh token rotation
7. Troubleshoot errors → troubleshooting/common-errors.md
- Error code tables (4700-4741 range)
- Quick diagnostic workflow
---
Documentation Structure
oauth/
├── SKILL.md # Main skill overview
├── SKILL.md # This file - navigation guide
│
├── concepts/ # Core OAuth concepts
│ ├── oauth-flows.md # 4 flows: S2S, User, Device, Chatbot
│ ├── token-lifecycle.md # Expiration, refresh, revocation
│ ├── pkce.md # PKCE security for public clients
│ ├── scopes-architecture.md # Classic vs Granular scopes
│ └── state-parameter.md # CSRF protection with state
│
├── examples/ # Complete working code
│ ├── s2s-oauth-basic.md # S2S OAuth minimal example
│ ├── s2s-oauth-redis.md # S2S OAuth with Redis caching (production)
│ ├── user-oauth-basic.md # User OAuth minimal example
│ ├── user-oauth-mysql.md # User OAuth with MySQL + encryption (production)
│ ├── device-flow.md # Device authorization flow
│ ├── pkce-implementation.md # PKCE for SPAs/mobile apps
│ └── token-refresh.md # Auto-refresh middleware pattern
│
├── troubleshooting/ # Problem solving guides
│ ├── common-errors.md # Error codes 4700-4741
│ ├── redirect-uri-issues.md # Most common OAuth error
│ ├── token-issues.md # Expired, revoked, invalid tokens
│ └── scope-issues.md # Scope mismatch errors
│
└── references/ # Reference documentation
├── oauth-errors.md # Complete error code reference
├── classic-scopes.md # Classic scope reference
└── granular-scopes.md # Granular scope reference---
By Use Case
I want to automate Zoom tasks on my own account
1. OAuth Flows - S2S OAuth explained 2. S2S OAuth Redis - Production pattern with Redis caching 3. Token Lifecycle - 1hr token, no refresh
I want to build a SaaS app for other Zoom users
1. OAuth Flows - User OAuth explained 2. User OAuth MySQL - Production pattern with encryption 3. Token Refresh - Automatic refresh middleware 4. Redirect URI Issues - Fix most common error
I want to build a mobile or SPA app
1. PKCE - Why PKCE is required for public clients 2. PKCE Implementation - Complete code example 3. State Parameter - CSRF protection
I want to build an app for devices without browsers (TV, kiosk)
1. OAuth Flows - Device flow explained 2. Device Flow Example - Complete polling implementation 3. Common Errors - Device-specific errors
I'm building a Team Chat bot
1. OAuth Flows - Chatbot flow explained 2. S2S OAuth Basic - Similar pattern, different grant type 3. Scopes Architecture - Chatbot-specific scopes
I'm getting redirect URI errors (4709)
1. Redirect URI Issues - START HERE! 2. Common Errors - Error details 3. User OAuth Basic - See correct pattern
I'm getting token errors (4700-4741)
1. Token Issues - Diagnostic workflow 2. Token Lifecycle - Understand expiration 3. Token Refresh - Implement auto-refresh 4. Common Errors - Error code tables
I'm getting scope errors (4711)
1. Scope Issues - Mismatch causes 2. Scopes Architecture - Classic vs Granular 3. Classic Scopes - Complete scope reference 4. Granular Scopes - Granular scope reference
I need to refresh tokens
1. Token Lifecycle - When to refresh 2. Token Refresh - Middleware pattern 3. Token Issues - Common mistakes
I want to understand the difference between Classic and Granular scopes
1. Scopes Architecture - Complete comparison 2. Classic Scopes - resource:level format 3. Granular Scopes - service:action:data_claim:access format
I need to secure my OAuth implementation
1. PKCE - Public client security 2. State Parameter - CSRF protection 3. User OAuth MySQL - Token encryption at rest
I want to migrate from JWT app to S2S OAuth
1. S2S OAuth Redis - Modern replacement 2. Token Lifecycle - Different token behavior
Note: JWT App Type was deprecated in June 2023. Migrate to S2S OAuth for server-to-server automation.
---
Most Critical Documents
1. OAuth Flows (DECISION DOCUMENT)
[concepts/oauth-flows.md](concepts/oauth-flows.md)
Understand which of the 4 flows to use:
- S2S OAuth: Backend automation (your account)
- User OAuth: SaaS apps (users authorize you)
- Device Flow: Devices without browsers
- Chatbot: Team Chat bots only
2. Token Lifecycle (MOST COMMON ISSUE)
[concepts/token-lifecycle.md](concepts/token-lifecycle.md)
99% of OAuth issues stem from misunderstanding:
- Token expiration (1 hour for all flows)
- Refresh token rotation (must save new refresh token)
- Revocation behavior (invalidates all tokens)
3. Redirect URI Issues (MOST COMMON ERROR)
[troubleshooting/redirect-uri-issues.md](troubleshooting/redirect-uri-issues.md)
Error 4709 ("Redirect URI mismatch") is the #1 OAuth error. Must match EXACTLY (including trailing slash, http vs https).
---
Key Learnings
Critical Discoveries:
1. Refresh Token Rotation
- Each refresh returns a NEW refresh token
- Old refresh token becomes invalid
- Failure to save new token causes 4735 errors
- See: Token Refresh
2. S2S OAuth Uses Redis, User OAuth Uses Database
- S2S: Single token for entire account → Redis (ephemeral)
- User: Per-user tokens → Database (persistent)
- See: S2S OAuth Redis vs User OAuth MySQL
3. Redirect URI Must Match EXACTLY
- Trailing slash matters:
/callback≠/callback/ - Protocol matters:
http://≠https:// - Port matters:
:3000≠:3001 - See: Redirect URI Issues
4. PKCE Required for Public Clients
- Mobile apps CANNOT keep secrets
- SPAs CANNOT keep secrets
- PKCE prevents authorization code interception
- See: PKCE
5. State Parameter Prevents CSRF
- Generate random state before redirect
- Store in session
- Verify on callback
- See: State Parameter
6. Token Storage Must Be Encrypted
- NEVER store tokens in plain text
- Use AES-256 minimum
- See: User OAuth MySQL
7. JWT App Type is Deprecated (June 2023)
- No new JWT apps can be created
- Existing apps still work but will eventually be sunset
- Migrate to S2S OAuth or User OAuth
8. Scope Levels Determine Authorization Requirements
- No suffix (user-level): Any user can authorize
:admin: Requires admin role:master: Requires account owner (multi-account)- See: Scopes Architecture
9. Authorization Codes Expire in 5 Minutes
- Exchange code for token immediately
- Don't cache authorization codes
- See: Token Lifecycle
10. Device Flow Requires Polling
- Poll at interval returned by
/devicecode(usually 5s) - Handle
authorization_pending,slow_down,expired_token - See: Device Flow
---
Quick Reference
"Which OAuth flow should I use?"
→ OAuth Flows
"Redirect URI mismatch error (4709)"
→ Redirect URI Issues
"Token expired or invalid"
→ Token Issues
"Refresh token invalid (4735)"
→ Token Refresh - Must save new refresh token
"Scope mismatch error (4711)"
→ Scope Issues
"How do I secure my OAuth app?"
→ PKCE + State Parameter
"How do I implement auto-refresh?"
→ Token Refresh
"What's the difference between Classic and Granular scopes?"
→ Scopes Architecture
"What error code means what?"
→ Common Errors
---
Document Version
Based on Zoom OAuth API v2 (2024+)
Deprecated: JWT App Type (June 2023)
---
Happy coding!
Remember: Start with OAuth Flows to understand which flow fits your use case!
Environment Variables
- See references/environment-variables.md for standardized
.envkeys and where to find each value.
Zoom OAuth Flows
Zoom supports 4 OAuth 2.0 flows. This guide helps you choose the right one and understand how each works.
Endpoint split to remember:
- Authorization URL:
https://zoom.us/oauth/authorize - Token URL:
https://zoom.us/oauth/token
Quick Decision Matrix
| 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 |
Two-Legged vs Three-Legged
| 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 |
---
1. Server-to-Server (S2S) OAuth
When to use:
- Backend automation on your own Zoom account
- No end-user interaction needed
- Account-wide API access
Grant type: account_credentials
Token lifetime:
- Access token: 1 hour
- Refresh token: None (request new token when expired)
Credentials required:
- Account ID
- Client ID
- Client Secret
Flow Diagram
┌──────────────┐ ┌──────────────┐
│ 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) │
│ │Implementation
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 }
};Key Points
✅ 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)
---
2. User Authorization OAuth
When to use:
- Building a SaaS app for other Zoom users
- Users authorize your app to act on their behalf
- Need per-user access control
Grant type: authorization_code
Token lifetime:
- Access token: 1 hour
- Refresh token: lifetime varies; ~90 days is common for some user-based flows (treat as changeable behavior)
Credentials required:
- Client ID
- Client Secret
- Redirect URI (must match marketplace app config)
Flow Diagram
┌────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 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" │ │
│─────────────────────────────────────────────────>│ │
│ │ │ │
│ 5. Redirect to callback │ │
│ {REDIRECT_URI}?code={CODE}&state={STATE} │ │
│<─────────────────────────────────────────────────│ │
│ │ │ │
│ 6. Send code to app │ │ │
│─────────────────────>│ │ │
│ │ │ │
│ │ 7. Exchange code for token │
│ │ POST /oauth/token │
│ │ grant_type=authorization_code │
│ │ code={CODE} │
│ │ redirect_uri={URI} │
│ │ Authorization: Basic {CLIENT_ID:CLIENT_SECRET} │
│ │─────────────────────────────────────────────────────>│
│ │ │ │
│ │ 8. Return tokens │ │
│ │ { access_token, refresh_token, expires_in } │
│ │<─────────────────────────────────────────────────────│
│ │ │ │
│ │ 9. Store tokens (encrypted) │
│ │ per user │ │
│ │ │ │
│ │ 10. API requests │
│ │ Authorization: Bearer {ACCESS_TOKEN} │
│ │─────────────────────────────────────────────────────────────────>│Implementation
Step 1: Redirect to Authorization
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());
});Step 2: Handle Callback and Exchange Code
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');
}
});Key Points
✅ 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
---
3. Device Authorization Flow
When to use:
- Devices without a browser (smart TVs, kiosks, IoT devices)
- Devices with limited input capabilities
- User authorizes on a separate device (phone/computer)
Grant type: urn:ietf:params:oauth:grant-type:device_code
Token lifetime:
- Access token: 1 hour
- Refresh token: lifetime varies; ~90 days is common for some user-based flows (treat as changeable behavior)
Credentials required:
- Client ID
- Client Secret
Flow Diagram
┌────────────┐ ┌──────────────┐ ┌────────────┐
│ 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!) │
│<───────────────────────│ │Implementation
Step 1: Request Device Code
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
}
*/
};Step 2: Display User Code
const { device_code, user_code, verification_uri, interval } = await requestDeviceCode();
console.log(`\nGo to: ${verification_uri}`);
console.log(`Enter code: ${user_code}\n`);Step 3: Poll for Token
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();
});
};Key Points
✅ 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
---
4. Client Authorization (Chatbot)
When to use:
- Building a Team Chat bot ONLY
- App needs
imchat:botscope - Simpler than S2S OAuth
Grant type: client_credentials
Token lifetime:
- Access token: 1 hour
- Refresh token: None (request new token when expired)
Credentials required:
- Client ID
- Client Secret
Flow Diagram
┌──────────────┐ ┌──────────────┐
│ 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) │
│ │Implementation
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 }
};Key Points
✅ 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
---
Comparison Table
| 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 |
---
OAuth 2.0 Standards
Zoom OAuth follows these RFCs:
- RFC 6749: OAuth 2.0 Authorization Framework
- https://datatracker.ietf.org/doc/html/rfc6749
- RFC 7636: PKCE (Proof Key for Code Exchange)
- https://datatracker.ietf.org/doc/html/rfc7636
- RFC 8628: Device Authorization Grant
- https://datatracker.ietf.org/doc/html/rfc8628
---
Next Steps
- Understand token lifecycle → token-lifecycle.md
- Learn about PKCE → pkce.md
- Implement your flow:
- S2S → ../examples/s2s-oauth-redis.md
- User → ../examples/user-oauth-mysql.md
- Device → ../examples/device-flow.md
PKCE (Proof Key for Code Exchange)
PKCE (pronounced "pixy") is a security extension to OAuth 2.0 for public clients that cannot securely store a client secret.
When PKCE is Required
| Client Type | Can Store Secrets? | PKCE Required? | Examples |
|---|---|---|---|
| Confidential | ✅ Yes (server-side) | ❌ Optional | Backend servers, traditional web apps |
| Public | ❌ No (client-side) | ✅ Required | Mobile apps, SPAs, desktop apps |
###Why Public Clients Can't Keep Secrets
// ❌ INSECURE: Client secret embedded in mobile/SPA code
const CLIENT_SECRET = "abc123"; // Anyone can decompile/inspect and find this!
// Attacker can:
// 1. Extract CLIENT_SECRET from app
// 2. Intercept authorization code
// 3. Exchange code for tokens using stolen secretHow PKCE Works
PKCE prevents authorization code interception attacks without requiring a client secret.
Flow Diagram
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Mobile App │ │ Zoom OAuth │ │ Attacker │
│ (Public) │ │ Server │ │ (Intercepting│
└──────┬──────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Generate code_verifier │ │
│ (random 43-128 chars) │ │
│ │ │
│ 2. Create code_challenge │ │
│ SHA256(code_verifier) │ │
│ │ │
│ 3. Authorize with challenge │ │
│https://zoom.us/oauth/authorize?│ │
│ code_challenge={HASH} │ │
│ code_challenge_method=S256 │ │
│──────────────────────────────>│ │
│ │ │
│ 4. User authorizes │ │
│──────────────────────────────>│ │
│ │ │
│ 5. Return authorization code │ │
│<──────────────────────────────│ │
│ │ │
│ │ Attacker intercepts code │
│ │<───────────────────────────────│
│ │ │
│ │ Attacker tries to exchange │
│ │ (but doesn't have verifier!) │
│ │<───────────────────────────────│
│ │ │
│ │ REJECTED: Missing verifier │
│ │───────────────────────────────>│
│ │ │
│ 6. Exchange code with verifier│ │
│ POST /oauth/token │ │
│ code={CODE} │ │
│ code_verifier={ORIGINAL} │ │
│──────────────────────────────>│ │
│ │ │
│ │ Verify: │
│ │ SHA256(code_verifier) │
│ │ == code_challenge? │
│ │ │
│ 7. Return tokens │ │
│<──────────────────────────────│ │
│ │ │Key Concept
- code_verifier: Random secret generated by app (kept secret)
- code_challenge: SHA256 hash of code_verifier (sent to Zoom)
- Zoom stores challenge: During authorization
- App proves possession: By providing original verifier during token exchange
- Attacker fails: Even with intercepted code, can't generate matching verifier
---
Implementation
Step 1: Generate PKCE Parameters
const crypto = require('crypto');
function generatePKCE() {
// Generate random code_verifier (43-128 characters)
const verifier = crypto
.randomBytes(32)
.toString('base64url'); // base64url encoding (no padding)
// Create code_challenge: SHA256(code_verifier)
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return {
code_verifier: verifier,
code_challenge: challenge
};
}
// Example output:
// {
// code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
// code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// }Step 2: Store code_verifier Securely
// Store in session (server-side) or secure storage (mobile)
req.session.pkce_verifier = code_verifier;
// For mobile apps, use secure storage:
// - iOS: Keychain
// - Android: EncryptedSharedPreferencesStep 3: Redirect to Authorization with code_challenge
app.get('/auth', (req, res) => {
const { code_verifier, code_challenge } = generatePKCE();
// Store verifier for later (Step 5)
req.session.pkce_verifier = code_verifier;
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('code_challenge', code_challenge);
authURL.searchParams.set('code_challenge_method', 'S256'); // SHA256
res.redirect(authURL.toString());
});Step 4: Exchange Code with code_verifier
app.get('/callback', async (req, res) => {
const { code } = req.query;
const code_verifier = req.session.pkce_verifier; // Retrieve stored verifier
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,
code_verifier: code_verifier // Prove possession of original verifier
}),
{
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;
// Success! Store tokens
await saveTokens({ access_token, refresh_token });
// Clean up verifier
delete req.session.pkce_verifier;
res.send('Authorization successful!');
} catch (error) {
res.status(500).send('Token exchange failed');
}
});---
PKCE Methods
Zoom supports two code_challenge_method values:
| Method | Description | Security | Support |
|---|---|---|---|
| S256 | SHA256 hash of verifier | ✅ Recommended | All OAuth 2.0 servers |
| plain | Verifier sent as-is (no hash) | ⚠️ Weaker | Legacy support only |
Always use S256:
// ✅ RECOMMENDED
authURL.searchParams.set('code_challenge_method', 'S256');// ❌ AVOID (less secure)
authURL.searchParams.set('code_challenge_method', 'plain');---
Security Benefits
Without PKCE (Vulnerable)
Attacker intercepts authorization code
↓
Attacker exchanges code with client_secret
↓
Attacker gets access_token and refresh_token
↓
Attacker has full account accessWith PKCE (Protected)
Attacker intercepts authorization code
↓
Attacker tries to exchange code
↓
Zoom: "Provide code_verifier"
↓
Attacker doesn't have original verifier
↓
Token exchange FAILS
↓
Legitimate app exchanges with correct verifier
↓
Legitimate app gets tokens---
Common Mistakes
1. Not Storing code_verifier
// ❌ WRONG: Generating new verifier on callback
app.get('/callback', async (req, res) => {
const { code_challenge } = generatePKCE(); // New verifier!
// This won't match the original challenge
});// ✅ CORRECT: Retrieve stored verifier
app.get('/callback', async (req, res) => {
const code_verifier = req.session.pkce_verifier; // Original verifier
});2. Using 'plain' Method
// ❌ AVOID
code_challenge_method: 'plain' // Less secure// ✅ RECOMMENDED
code_challenge_method: 'S256' // SHA256 hashing3. Exposing code_verifier
// ❌ WRONG: Including verifier in URL
authURL.searchParams.set('code_verifier', verifier); // Don't send verifier during auth!// ✅ CORRECT: Only send code_challenge
authURL.searchParams.set('code_challenge', challenge);
authURL.searchParams.set('code_challenge_method', 'S256');---
Mobile App Implementation
iOS (Swift)
import CryptoKit
func generatePKCE() -> (verifier: String, challenge: String) {
// Generate random verifier
var buffer = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
let verifier = Data(buffer).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
// Create SHA256 challenge
let data = verifier.data(using: .utf8)!
let hash = SHA256.hash(data: data)
let challenge = Data(hash).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return (verifier, challenge)
}
// Store verifier in Keychain
KeychainWrapper.standard.set(verifier, forKey: "pkce_verifier")Android (Kotlin)
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
fun generatePKCE(): Pair<String, String> {
// Generate random verifier
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
val verifier = Base64.encodeToString(bytes,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
// Create SHA256 challenge
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(verifier.toByteArray())
val challenge = Base64.encodeToString(hash,
Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
return Pair(verifier, challenge)
}
// Store verifier in EncryptedSharedPreferences
val encryptedPrefs = EncryptedSharedPreferences.create(...)
encryptedPrefs.edit().putString("pkce_verifier", verifier).apply()---
Testing PKCE Implementation
1. Verify code_challenge Format
const { code_verifier, code_challenge } = generatePKCE();
console.log('Verifier length:', code_verifier.length); // Should be 43-128
console.log('Challenge length:', code_challenge.length); // Should be 43 for S256
console.log('Verifier chars:', /^[A-Za-z0-9_-]+$/.test(code_verifier)); // true
console.log('Challenge chars:', /^[A-Za-z0-9_-]+$/.test(code_challenge)); // true2. Verify SHA256 Hashing
const crypto = require('crypto');
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const expected_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const computed_challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
console.log(computed_challenge === expected_challenge); // Should be true3. Test Token Exchange
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" \
-d "code=YOUR_AUTH_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI" \
-d "code_verifier=YOUR_CODE_VERIFIER"
# Should return { access_token, refresh_token, ... }---
PKCE Specification
PKCE follows RFC 7636:
- https://datatracker.ietf.org/doc/html/rfc7636
---
Next Steps
- Implement PKCE in your app → ../examples/pkce-implementation.md
- Add state parameter → state-parameter.md
- Understand OAuth flows → oauth-flows.md
Scopes Architecture
Zoom OAuth uses scopes to limit API access. Understanding Classic vs Granular scopes is critical.
Scope Types
| Type | Format | Example | Status |
|---|---|---|---|
| Classic | resource:level | meeting:write:admin | Active |
| Granular | service:action:data_claim:access | meeting:write:meeting:admin | Active (newer) |
Classic Scopes
Format
{resource}:{action}:{level}Examples:
meeting:read- Read user's own meetingsmeeting:write:admin- Create/update meetings for all account usersrecording:read:master- Read recordings across all sub-accounts
Scope Levels
| Level | Access | Who Can Authorize | Example |
|---|---|---|---|
| (none) | Own data only | Any user | meeting:read |
:admin | Account-wide | Admin role required | meeting:write:admin |
:master | Multi-account | Account owner only | user:master |
Common Classic Scopes
meeting:read # View own meetings
meeting:write # Create/edit own meetings
meeting:write:admin # Manage all account meetings
user:read # View own profile
user:write:admin # Manage account users
recording:read # View own recordings
recording:write:admin # Manage account recordings
webinar:read # View own webinars
webinar:write:admin # Manage account webinars
imchat:bot # Team Chat bot accessGranular Scopes
Format
{service}:{action}:{data_claim}:{access_level}Examples:
meeting:read:meeting:user- Read user's own meetingsmeeting:write:invite_links:admin- Create invite links for accountrecording:delete:recording_file:admin- Delete recording files account-wide
Components
1. service: API category (meeting, user, recording, etc.) 2. action: Operation (read, write, delete, etc.) 3. data_claim: Specific data type (meeting, participant, invite_links, etc.) 4. access_level: Scope of access (user, admin, account, etc.)
Access Levels (Granular)
| Level | Access | Example |
|---|---|---|
user | Own data | meeting:read:meeting:user |
admin | Account-wide | meeting:write:meeting:admin |
account | Account settings | account:read:settings:account |
Classic vs Granular Comparison
Meetings Scope Example
| Classic | Granular Equivalent |
|---|---|
meeting:read | meeting:read:meeting:user + meeting:read:list_meetings:user |
meeting:write:admin | meeting:write:meeting:admin + meeting:write:settings:admin + more |
Why Granular Scopes Exist
Classic scopes are broad:
meeting:write:admingrants ALL meeting write permissions account-wide- Includes create, update, delete, settings, etc.
Granular scopes are specific:
meeting:write:meeting:admin- Only create/update meetingsmeeting:delete:meeting:admin- Only delete meetingsmeeting:write:settings:admin- Only update settings
Principle of Least Privilege: Request only the granular scopes you need.
Choosing Between Classic and Granular
Use Classic When:
- You need broad access (e.g., full meeting management)
- Simpler scope management preferred
- Legacy app migration
Use Granular When:
- You need specific permissions only
- Implementing principle of least privilege
- Building security-sensitive apps
Can You Mix?
✅ Yes, you can request both Classic and Granular scopes in the same app.
scope=meeting:read user:write:admin meeting:write:invite_links:adminRequesting Scopes
During App Creation (S2S OAuth, Chatbot)
Scopes are configured in Zoom Marketplace: 1. Go to your app in https://marketplace.zoom.us 2. Click "Scopes" tab 3. Select required scopes (Classic or Granular) 4. Click "Continue"
Token will include all configured scopes.
During Authorization (User OAuth, Device Flow)
Scopes are requested in authorization URL:
const authURL = new URL('https://zoom.us/oauth/authorize');
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('client_id', CLIENT_ID);
authURL.searchParams.set('redirect_uri', REDIRECT_URI);
// Request specific scopes (space-separated)
authURL.searchParams.set('scope', 'meeting:read user:read recording:read');
// User sees consent screen listing these scopesScope Consent Screen
When user authorizes your app, they see:
[Your App Name] wants to:
✓ View your meetings (meeting:read)
✓ View your profile (user:read)
✓ View your recordings (recording:read)
[Deny] [Authorize]Scope Errors
Error 4711: Scope Mismatch
Cause: Token's scopes don't include required scope for API endpoint.
Example:
// Token has: meeting:read
// API requires: meeting:write
await axios.post('https://api.zoom.us/v2/users/me/meetings', {...}, {
headers: { Authorization: `Bearer ${token}` }
});
// Error 4711: Insufficient scopeSolution: 1. Add required scope in Zoom Marketplace (S2S/Chatbot) 2. OR request scope in authorization URL (User/Device) 3. Re-authorize user to grant new scopes
Checking Token Scopes
Decode Access Token Scopes
// S2S OAuth: scopes returned in token response
const { access_token, scope } = tokenResponse.data;
console.log('Scopes:', scope); // "meeting:read user:read recording:write"
// User OAuth: scopes returned during token exchange
const { access_token, scope } = tokenResponse.data;
console.log('Granted scopes:', scope.split(' ')); // ['meeting:read', 'user:read', ...]API to Get Token Scopes
curl -H "Authorization: Bearer {access_token}" \
https://zoom.us/oauth/tokenBest Practices
1. Request Minimum Scopes Needed
// ❌ AVOID: Requesting broad admin access when not needed
scope: "meeting:write:admin user:write:admin recording:write:admin"
// ✅ PREFER: Request only what you need
scope: "meeting:read user:read"2. Use Granular Scopes for Specific Operations
// ❌ Classic (broad)
scope: "meeting:write:admin" // Includes create, update, delete, settings, etc.
// ✅ Granular (specific)
scope: "meeting:write:meeting:admin" // Only create/update meetings3. Document Required Scopes
/**
* Create a meeting for a user
* Required scope: meeting:write:admin (Classic) or meeting:write:meeting:admin (Granular)
*/
async function createMeeting(userId, meetingData) {
// ...
}Reference Documentation
- Classic Scopes → ../references/classic-scopes.md
- Granular Scopes → ../references/granular-scopes.md
- Scope Errors → ../troubleshooting/scope-issues.md
State Parameter (CSRF Protection)
The state parameter prevents Cross-Site Request Forgery (CSRF) attacks in OAuth flows.
What is CSRF in OAuth?
Attack Scenario (Without State)
1. Attacker initiates OAuth flow for victim's account
2. Attacker gets authorization code in callback
3. Attacker tricks victim into visiting callback URL with attacker's code
4. Victim's app exchanges code and links attacker's Zoom account to victim's app account
5. Attacker now has access to victim's app dataProtection (With State)
1. App generates random state before redirecting to OAuth
2. App stores state in user's session
3. Zoom includes state in callback
4. App verifies state matches session
5. If state doesn't match → Reject (CSRF detected)Implementation
Step 1: Generate Random State
const crypto = require('crypto');
app.get('/auth', (req, res) => {
// Generate cryptographically secure random state
const state = crypto.randomBytes(16).toString('hex');
// Store in session (server-side)
req.session.oauthState = state;
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); // Include state
res.redirect(authURL.toString());
});Step 2: Verify State in Callback
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
const sessionState = req.session.oauthState;
// Verify state matches
if (state !== sessionState) {
return res.status(403).send('Invalid state parameter - possible CSRF attack');
}
// Clean up state (one-time use)
delete req.session.oauthState;
// Proceed with token exchange
const tokens = await exchangeCodeForToken(code);
// ...
});State Parameter Flow
┌─────────────┐ ┌──────────────┐ ┌──────────────┐
│ Your App │ │ User Session│ │ Zoom OAuth │
└──────┬──────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Generate state │ │
│ state = "abc123" │ │
│ │ │
│ 2. Store in session │ │
│────────────────────────────>│ session.oauthState = "abc123"
│ │ │
│ 3. Redirect to authorize with state │
│https://zoom.us/oauth/authorize?state=abc123 │
│───────────────────────────────────────────────────────────>│
│ │ │
│ 4. User authorizes │ │
│ │ │
│ 5. Redirect to callback with state │
│ /callback?code=xyz&state=abc123 │
│<───────────────────────────────────────────────────────────│
│ │ │
│ 6. Retrieve session state │ │
│<────────────────────────────│ sessionState = "abc123" │
│ │ │
│ 7. Verify state === sessionState │
│ "abc123" === "abc123" ✓ │ │
│ │ │
│ 8. Exchange code for token │ │
│───────────────────────────────────────────────────────────>│
│ │ │Common Mistakes
1. Not Verifying State
// ❌ WRONG: Accepting any state
app.get('/callback', async (req, res) => {
const { code } = req.query;
// No state verification!
await exchangeCodeForToken(code);
});// ✅ CORRECT: Verifying state
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
if (state !== req.session.oauthState) {
return res.status(403).send('CSRF detected');
}
await exchangeCodeForToken(code);
});2. Using Predictable State
// ❌ WRONG: Predictable state
const state = Date.now().toString(); // Attacker can predict!// ✅ CORRECT: Cryptographically random state
const state = crypto.randomBytes(16).toString('hex');3. Reusing State
// ❌ WRONG: Not deleting state after use
if (state === req.session.oauthState) {
// State remains in session - can be reused!
}// ✅ CORRECT: Delete state after verification
if (state === req.session.oauthState) {
delete req.session.oauthState; // One-time use
}State + PKCE Together
For maximum security, use both:
app.get('/auth', (req, res) => {
// Generate state (CSRF protection)
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;
// Generate PKCE (authorization code interception protection)
const { code_verifier, code_challenge } = generatePKCE();
req.session.pkceVerifier = code_verifier;
const authURL = new URL('https://zoom.us/oauth/authorize');
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('client_id', CLIENT_ID);
authURL.searchParams.set('redirect_uri', REDIRECT_URI);
authURL.searchParams.set('state', state); // CSRF protection
authURL.searchParams.set('code_challenge', code_challenge); // PKCE
authURL.searchParams.set('code_challenge_method', 'S256');
res.redirect(authURL.toString());
});
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// Verify state (CSRF)
if (state !== req.session.oauthState) {
return res.status(403).send('CSRF detected');
}
// Exchange with code_verifier (PKCE)
const code_verifier = req.session.pkceVerifier;
const tokens = await exchangeCode(code, code_verifier);
// Clean up
delete req.session.oauthState;
delete req.session.pkceVerifier;
});Mobile Apps
For mobile apps without server-side sessions:
// Store state in secure storage
const state = generateRandomString();
await SecureStore.setItemAsync('oauth_state', state);
// Verify in callback
const storedState = await SecureStore.getItemAsync('oauth_state');
if (receivedState !== storedState) {
throw new Error('CSRF detected');
}
// Clean up
await SecureStore.deleteItemAsync('oauth_state');Best Practices
1. Always use state for User OAuth and Device Flow 2. Generate cryptographically random state (not timestamps, sequential IDs) 3. Store state server-side in session (not client-side cookies) 4. Delete state after verification (one-time use) 5. Combine with PKCE for mobile/SPA apps
Next Steps
- Implement state + PKCE → ../examples/pkce-implementation.md
- Understand PKCE → pkce.md
- Fix OAuth errors → ../troubleshooting/common-errors.md
Token Lifecycle
Understanding how Zoom OAuth tokens are created, expire, refresh, and revoke is critical for building reliable integrations.
Token Types
Access Token
- Purpose: Authenticate API requests
- Lifetime: 1 hour (all OAuth flows)
- Usage:
Authorization: Bearer {access_token}header - Format: Opaque string (not JWT)
Refresh Token
- Purpose: Obtain new access tokens without user re-authorization
- Lifetime: Varies by flow/account/app configuration; ~90 days is common for some user-based flows (treat as changeable behavior)
- Availability: S2S OAuth and Chatbot do NOT have refresh tokens
- Rotation: Each refresh returns a NEW refresh token (old one becomes invalid)
Authorization Code
- Purpose: Temporary code exchanged for access token
- Lifetime: 5 minutes
- Usage: User OAuth and Device Flow only
- One-time use: Code becomes invalid after exchange
---
Expiration Summary
| Flow | Access Token | Refresh Token | Strategy |
|---|---|---|---|
| S2S OAuth | 1 hour | None | Request new token before expiration |
| User OAuth | 1 hour | ~90 days (commonly) | Use refresh token to get new access token |
| Device Flow | 1 hour | ~90 days (commonly) | Use refresh token to get new access token |
| Chatbot | 1 hour | None | Request new token before expiration |
---
S2S OAuth & Chatbot Token Lifecycle
Timeline
┌────────────────────────────────────────────────────┐
│ │
│ Token Request │
│ │ │
│ v │
│ [ Access Token Valid ] │
│ │
│ ├───────────────────── 1 hour ──────────────────┤ │
│ │
│ Token │
│ Expires │
│ │ │
│ v │
│ Request New Token ────────────────> [ New Access Token Valid ]
│ │
└────────────────────────────────────────────────────┘Strategy: Cache with TTL
const redis = require('redis');
const client = redis.createClient();
const getToken = async () => {
// Check cache first
let token = await client.get('zoom_access_token');
if (!token) {
// Request new token
const response = await axios.post('https://zoom.us/oauth/token', ...);
const { access_token, expires_in } = response.data;
// Cache with TTL (10 second buffer before actual expiration)
await client.setex('zoom_access_token', expires_in - 10, access_token);
token = access_token;
}
return token;
};Key Points:
- ✅ Cache token in Redis with TTL matching expiration
- ✅ Use 10-second buffer to prevent race conditions
- ✅ Single token shared across all requests
- ❌ Do NOT request new token on every API call
- ❌ Do NOT try to "refresh" (no refresh token exists)
---
User OAuth & Device Flow Token Lifecycle
Timeline
┌────────────────────────────────────────────────────────────────────┐
│ │
│ User Authorizes │
│ │ │
│ v │
│ [ Access Token Valid ] │
│ [ Refresh Token Valid ]────────────────────────────────────────┐ │
│ │ │
│ ├───────────── 1 hour ────────────┤ │ │
│ │ │
│ Access Token Expires │ │
│ │ │ │
│ v │ │
│ Refresh Request ──────────> [ New Access Token Valid ] │ │
│ [ New Refresh Token Valid ]────┐ │ │
│ │ │ │
│ ├───────────── 1 hour ────────────┤ │ │ │
│ │ │ │
│ Access Token Expires │ │ │
│ │ │ │ │
│ v │ │ │
│ Refresh Request ──────────> [ New Access Token Valid ] │ │ │
│ [ New Refresh Token Valid ] │ │ │
│ │ │ │
│ ... Continue refreshing up to ~90 days (commonly) ... │ │ │
│ │ │ │
│ ├──────────────────────── ~90 days (commonly) ───────────┤ │ │
│ │ │
│ Refresh Token Expires │ │
│ │ │ │
│ v │ │
│ User Must Re-Authorize (restart OAuth flow) │ │
│ │
└────────────────────────────────────────────────────────────────────┘Strategy: Auto-Refresh Middleware
const tokenMiddleware = async (req, res, next) => {
const userId = req.session.userId;
// Get user's tokens from database
let { access_token, refresh_token, token_expiry } = await getUserTokens(userId);
// Check if access token is expired or will expire soon (5 minute buffer)
const now = Date.now();
const expiresIn = token_expiry - now;
if (expiresIn < 300000) { // Less than 5 minutes remaining
// Refresh the token
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'refresh_token',
refresh_token: refresh_token
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${CLIENT_ID}:${CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token: new_access_token, refresh_token: new_refresh_token, expires_in } = response.data;
// CRITICAL: Update BOTH tokens in database
await updateUserTokens(userId, {
access_token: new_access_token,
refresh_token: new_refresh_token, // NEW refresh token
token_expiry: now + (expires_in * 1000)
});
access_token = new_access_token;
}
// Attach token to request
req.zoomToken = access_token;
next();
};Key Points:
- ✅ Refresh BEFORE token expires (5-minute buffer recommended)
- ✅ ALWAYS save the NEW refresh token (old one becomes invalid)
- ✅ Store tokens per user in database
- ✅ Encrypt tokens at rest (AES-256 minimum)
- ❌ Do NOT reuse old refresh token after refresh
- ❌ Do NOT wait for API 401 errors to trigger refresh
---
Refresh Token Rotation
CRITICAL: Zoom rotates refresh tokens on every refresh.
What Happens During Refresh
Before Refresh:
access_token: "abc123" (expired)
refresh_token: "xyz789" (valid)
Request:
POST /oauth/token
grant_type=refresh_token
refresh_token=xyz789
Response:
{
"access_token": "def456", // NEW access token
"refresh_token": "uvw012", // NEW refresh token
"expires_in": 3600
}
After Refresh:
access_token: "def456" (valid for 1 hour)
refresh_token: "uvw012" (lifetime varies; ~90 days is common)
OLD refresh_token "xyz789" is NOW INVALIDCommon Mistake
// ❌ WRONG: Not saving new refresh token
const response = await refreshToken(old_refresh_token);
const { access_token } = response.data; // Only saving access token
await updateUserTokens(userId, { access_token }); // Refresh token not updated!
// Next refresh will fail with error 4735 "Invalid refresh token"// ✅ CORRECT: Saving both tokens
const response = await refreshToken(old_refresh_token);
const { access_token, refresh_token } = response.data;
await updateUserTokens(userId, {
access_token,
refresh_token // MUST save new refresh token
});---
Authorization Code Expiration
Lifetime: 5 minutes
Timeline
User Clicks "Allow"
│
v
Authorization Code Issued (expires in 5 minutes)
│
│ ← Exchange code for token within 5 minutes
v
[ Access Token + Refresh Token ]
If code not exchanged within 5 minutes:
→ Error 4733 "Invalid authorization code"
→ User must re-authorizeImplementation
app.get('/callback', async (req, res) => {
const { code } = req.query;
try {
// Exchange code for token IMMEDIATELY
const response = await axios.post('https://zoom.us/oauth/token', {
grant_type: 'authorization_code',
code: code,
redirect_uri: process.env.REDIRECT_URI
}, ...);
// Store tokens
await saveTokens(response.data);
} catch (error) {
if (error.response?.data?.error === 'invalid_grant') {
// Code expired (4733) or already used
res.send('Authorization code expired. Please re-authorize.');
}
}
});Key Points:
- ✅ Exchange authorization code immediately upon receiving it
- ✅ Authorization codes are one-time use
- ❌ Do NOT cache or store authorization codes
- ❌ Do NOT delay token exchange
---
Token Revocation
When Tokens Are Revoked
1. User re-authorizes your app:
- All previous tokens for that user become invalid
- New tokens are issued
2. User removes your app:
- All tokens for that user become invalid
- User must re-authorize to grant access again
3. Explicit revocation:
- Your app calls
https://zoom.us/oauth/revokeendpoint - Tokens become invalid immediately
4. Refresh token expires (lifetime varies):
- Can no longer refresh
- User must re-authorize
Revoke Token API
const revokeToken = async (access_token) => {
await axios.post(
'https://zoom.us/oauth/revoke',
qs.stringify({
token: access_token
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${CLIENT_ID}:${CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
// Token is now revoked
// Delete from database
await deleteUserTokens(userId);
};What Gets Revoked:
- Access token becomes invalid immediately
- Refresh token becomes invalid immediately
- All API requests with revoked token return 401
---
Error Codes
| Code | Error | Meaning | Action |
|---|---|---|---|
| 4733 | Invalid authorization code | Code expired (5 min) or already used | User must re-authorize |
| 4735 | Invalid refresh token | Refresh token expired or rotated | User must re-authorize |
| 4741 | Token has been revoked | Token was explicitly revoked | User must re-authorize |
| 401 | Unauthorized | Access token expired or invalid | Refresh token (if available) or re-authorize |
---
Best Practices
1. Cache S2S Tokens
// ✅ Cache in Redis with TTL
await redis.setex('zoom_token', expires_in - 10, access_token);// ❌ Request new token on every API call
const token = await getToken(); // Every time? No!
await makeAPIRequest(token);2. Refresh BEFORE Expiration
// ✅ Refresh with buffer (5 minutes before expiry)
if (expiresIn < 300000) {
await refreshToken();
}// ❌ Wait for 401 error
try {
await makeAPIRequest(token);
} catch (err) {
if (err.status === 401) {
await refreshToken(); // Too late!
}
}3. Always Save New Refresh Token
// ✅ Update both tokens
const { access_token, refresh_token } = await refresh();
await saveTokens({ access_token, refresh_token });// ❌ Only save access token
const { access_token } = await refresh();
await saveTokens({ access_token }); // Refresh token not saved!4. Encrypt Tokens at Rest
// ✅ Encrypt before storing
const encrypted = encrypt(access_token, CIPHER_KEY);
await db.query('UPDATE users SET token = ? WHERE id = ?', [encrypted, userId]);// ❌ Store in plain text
await db.query('UPDATE users SET token = ? WHERE id = ?', [access_token, userId]);5. Handle Revocation Gracefully
// ✅ Detect revoked tokens and prompt re-auth
if (error.code === 4741) {
await deleteUserTokens(userId);
res.redirect('/auth'); // Re-authorize
}---
Debugging Token Issues
Symptom: "Token expired" immediately after getting it
Cause: Server clock is incorrect
Solution:
# Sync server time
sudo ntpdate -s time.nist.govSymptom: Refresh fails with "Invalid refresh token" (4735)
Cause: Using old refresh token after it was rotated
Solution:
- Check database: Are you saving the NEW refresh token?
- Check code: Are you updating BOTH access_token AND refresh_token?
Symptom: Authorization code fails with "Invalid grant" (4733)
Cause: Code expired (5 minutes passed) or already used
Solution:
- Exchange code immediately in callback
- Codes are one-time use (don't cache)
Symptom: All tokens revoked unexpectedly
Cause: User re-authorized your app or removed it
Solution:
- Detect 401/4741 errors
- Prompt user to re-authorize
---
Next Steps
- Implement auto-refresh → ../examples/token-refresh.md
- Fix token errors → ../troubleshooting/token-issues.md
- Understand OAuth flows → oauth-flows.md
Device Flow
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
Pkce Implementation
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
S2s Oauth Basic
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
S2S OAuth with Redis Caching (Production Pattern)
Production-ready Server-to-Server OAuth implementation with Redis token caching and auto-refresh middleware.
Architecture
Express App
↓
tokenCheck Middleware (automatic token management)
↓
Redis Cache (TTL-based expiration)
↓
Zoom API Routes (protected)Complete Implementation
1. Dependencies
npm install express redis axios query-string dotenv2. Redis Configuration
// configs/redis.js
const redis = require('redis');
const client = redis.createClient({
url: process.env.REDIS_URL || 'redis://YOUR_REDIS_HOST:6379'
});
client.on('error', (err) => console.error('Redis error:', err));
client.on('connect', () => console.log('Connected to Redis'));
module.exports = client;3. Token Utilities
// utils/token.js
const axios = require('axios');
const qs = require('query-string');
const { ZOOM_ACCOUNT_ID, ZOOM_CLIENT_ID, ZOOM_CLIENT_SECRET } = process.env;
const getToken = async () => {
try {
const response = await axios.post(
'https://zoom.us/oauth/token',
qs.stringify({
grant_type: 'account_credentials',
account_id: ZOOM_ACCOUNT_ID
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(
`${ZOOM_CLIENT_ID}:${ZOOM_CLIENT_SECRET}`
).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
return response.data; // { access_token, expires_in, scope }
} catch (error) {
throw new Error(`Token request failed: ${error.response?.data?.message || error.message}`);
}
};
const setToken = async (redis, { access_token, expires_in }) => {
// Cache with TTL (10 second buffer before actual expiration)
await redis.setex('access_token', expires_in - 10, access_token);
};
module.exports = { getToken, setToken };4. Token Check Middleware
// middlewares/tokenCheck.js
const redis = require('../configs/redis');
const { getToken, setToken } = require('../utils/token');
const tokenCheck = async (req, res, next) => {
let token = await redis.get('access_token');
// Redis returns null if key doesn't exist
if (!token) {
try {
const { access_token, expires_in, error } = await getToken();
if (error) {
return res.status(401).json({
message: `Authentication failed: ${error.message}`
});
}
// Cache token
await setToken(redis, { access_token, expires_in });
token = access_token;
} catch (err) {
return res.status(500).json({
message: 'Token generation failed',
error: err.message
});
}
}
// Attach token to request for route handlers
req.headerConfig = {
headers: {
Authorization: `Bearer ${token}`
}
};
next();
};
module.exports = { tokenCheck };5. Main Application
// index.js
require('dotenv').config();
const express = require('express');
const redis = require('./configs/redis');
const { tokenCheck } = require('./middlewares/tokenCheck');
const app = express();
const PORT = process.env.PORT || 8080;
// Connect to Redis
(async () => {
await redis.connect();
})();
// Add global middlewares
app.use(express.json());
// Apply tokenCheck to all API routes
app.use('/api/users', tokenCheck, require('./routes/api/users'));
app.use('/api/meetings', tokenCheck, require('./routes/api/meetings'));
const server = app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
// Graceful shutdown
const cleanup = async () => {
console.log('Shutting down gracefully...');
await redis.del('access_token'); // Clear cached token
server.close(() => {
redis.quit(() => process.exit());
});
};
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);6. Example API Route
// routes/api/users.js
const express = require('express');
const axios = require('axios');
const router = express.Router();
const ZOOM_API_BASE = 'https://api.zoom.us/v2';
// List users
router.get('/', async (req, res) => {
try {
const response = await axios.get(
`${ZOOM_API_BASE}/users`,
req.headerConfig // Token from middleware
);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json({
message: 'Failed to list users',
error: error.response?.data || error.message
});
}
});
// Get user
router.get('/:userId', async (req, res) => {
try {
const response = await axios.get(
`${ZOOM_API_BASE}/users/${req.params.userId}`,
req.headerConfig
);
res.json(response.data);
} catch (error) {
res.status(error.response?.status || 500).json({
message: 'Failed to get user',
error: error.response?.data || error.message
});
}
});
module.exports = router;7. Environment Variables
# .env
ZOOM_ACCOUNT_ID=your_account_id
ZOOM_CLIENT_ID=your_client_id
ZOOM_CLIENT_SECRET=your_client_secret
REDIS_URL=redis://YOUR_REDIS_HOST:6379
PORT=8080How It Works
1. Request arrives at protected route (e.g., GET /api/users) 2. tokenCheck middleware runs:
- Checks Redis for cached token
- If missing: Requests new token from Zoom
- Caches token with TTL (expires_in - 10 seconds)
3. Token attached to req.headerConfig 4. Route handler makes API request with token 5. Token auto-refreshes when Redis TTL expires
Benefits
✅ Automatic token management - No manual refresh logic ✅ Single token for all requests - Account-wide access ✅ TTL-based expiration - Redis handles cleanup ✅ 10-second buffer - Prevents race conditions ✅ Graceful shutdown - Clears token on exit
Testing
# Start Redis
docker run -d -p 6379:6379 redis
# Start app
npm start
# Test endpoints
API_BASE_URL="http://YOUR_API_HOST:8080"
curl "$API_BASE_URL/api/users"Docker Deployment
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
app:
build: .
ports:
- "8080:8080"
environment:
- ZOOM_ACCOUNT_ID=${ZOOM_ACCOUNT_ID}
- ZOOM_CLIENT_ID=${ZOOM_CLIENT_ID}
- ZOOM_CLIENT_SECRET=${ZOOM_CLIENT_SECRET}
- REDIS_URL=redis://redis:6379
depends_on:
- redisRelated
- S2S OAuth flow → ../concepts/oauth-flows.md#server-to-server-s2s-oauth
- Token lifecycle → ../concepts/token-lifecycle.md
Token Refresh
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
User Oauth Basic
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
User Oauth Mysql
See ../concepts/oauth-flows.md and official Zoom samples for implementation details.
Official sample repositories:
- https://github.com/zoom/oauth-sample-app
- https://github.com/zoom/server-to-server-oauth-token
- https://github.com/zoom/server-to-server-oauth-starter-api
- https://github.com/zoom/user-level-oauth-starter
Zoom OAuth Environment Variables
Standard .env keys
| Variable | Required | Used for | Where to find |
|---|---|---|---|
ZOOM_CLIENT_ID | Yes | OAuth client identity | Zoom Marketplace -> OAuth app -> App Credentials |
ZOOM_CLIENT_SECRET | Yes | OAuth client secret | Zoom Marketplace -> OAuth app -> App Credentials |
ZOOM_REDIRECT_URI | User-level OAuth | Authorization code callback | Zoom Marketplace -> OAuth redirect/allow list |
ZOOM_ACCOUNT_ID | S2S OAuth | Account-level token grant | Zoom Marketplace -> Server-to-Server OAuth app credentials |
Runtime-only values
ZOOM_AUTH_CODEZOOM_ACCESS_TOKENZOOM_REFRESH_TOKEN
Generate these at runtime and keep in secure storage.
Notes
- Use S2S OAuth where user consent is not required.
- Use Authorization Code flow when acting on behalf of a Zoom user.
OAuth Error Messages
Source: https://developers.zoom.us/docs/integrations/oauth/
This table lists OAuth error messages, possible causes, and recommended mitigations.
| Error Code | Error Message | Description | Guidance |
|---|---|---|---|
| 4700 | (empty) | The cause can vary depending on the API. | Use the tracking ID to find additional information in the logs and contact Zoom for further assistance. |
| 4700 | Token cannot be empty. | The token is missing. | Verify that the token is present in the header and that its value is correct. |
| 4700 | Exception message | This is a catch all for unexpected errors. | Report the error code to Zoom for further assistance. |
| 4702, 4704 | Invalid client. / Invalid client secret. | Client ID does not match authenticated client. The client ID or client secret isn't entered correctly, or the related app doesn't exist. | Verify that the client ID and client secret are entered in the header correctly. If they are correct then contact Zoom for further assistance. |
| 4705 | Grant type is not supported from token endpoint. | The grant type is not supported by the token endpoint. | Use a valid grant type against https://zoom.us/oauth/token (for example authorization_code, refresh_token, account_credentials, client_credentials, urn:ietf:params:oauth:grant-type:device_code). |
| 4706 | Client ID or client secret is missing. | The client ID and client secret are missing either in the header or in the request parameter. | Verify that the client ID and client secret are entered correctly in the header or request parameter. |
| 4706 | Missing grant type. | OAuth requires the grant type, and it is missing in the header. | Verify that the grant type is entered in the header. |
| 4709 | Redirect URI mismatch. | The redirect_uri is missing or the value is null or is incorrect. | Verify that the redirect_uri is entered correctly. |
| 4711 | Refresh token invalid. | The token scopes do not match with the client scopes. | Verify that there isn't a mismatch between the token's scopes and the client's scopes. |
| 4717 | The app has been disabled | The app has been disabled. | Contact Zoom support to enable the app. |
| 4724 | Exception error message. | An invalid JWT token is passed in the header. | Verify that the JWT token is correctly signed and that the token passed in the header is valid. |
| 4732 | Creating authorization code error. | The lookup service may be down. ELK logs usually throw a /lookup/v1/indexes POST 5005 Internal Server error. | Contact your DNS lookup service provider to verify the server status, or contact Zoom for further support. |
| 4733 | Code is expired | Authorization codes have an expiration time of 5 minutes. | Regenerate the authorization code. |
| 4734 | Invalid authorization code. | The authorization code is invalid. | Regenerate the authorization code. |
| 4735 | The owner of the token does not exist. | The user ID of the token doesn't exist. This might happen if the refresh token was issued for a user who has since been removed from an account. The user ID is stored in the uid field of a token. | Verify the uid for the token is valid and entered correctly. |
| 4737 | Can not find the authentication for the access token. | The refresh token isn't found in the DynamoDB table. | Contact Zoom and request to reauthorize the app. |
| 4738 | The token is disabled by admin. | An admin turned off pre-approval for the related app for users under an account. | Contact Zoom for further support. |
| 4740 | The token ID is out of the token tolerance range. | The maximum number of times a refresh token is allowed to be used has been surpassed. Tolerance errors happen with version 7 tokens. Version 8 and later tokens do not use the tolerance mechanism. | Contact Zoom for assistance with reconfiguring the tolerance range. |
| 4741 | The token has been revoked. | This happens when you perform multiple authorizations. With multiple authorizations, the last token issued is considered valid, and the previous ones are invalidated. | Make sure you are using the latest and valid authorization token. |
Common Issues Quick Reference
| Symptom | Check |
|---|---|
| Empty error (4700) | Check tracking ID in logs |
| Invalid client (4702/4704) | Verify Client ID and Client Secret |
| Grant type error (4705) | Use: refresh_token, authorization_code, device_auth, account_credentials |
| Missing credentials (4706) | Ensure Client ID/Secret in header or request params |
| Redirect mismatch (4709) | Verify redirect_uri matches app configuration |
| Token scope mismatch (4711) | Compare token scopes vs client scopes |
| Code expired (4733) | Authorization codes expire in 5 minutes |
| Invalid code (4734) | Regenerate authorization code |
| Token revoked (4741) | Use the most recent token from latest authorization |
OAuth 5-Minute Preflight Runbook
Use this before deep debugging. It catches common OAuth failures fast.
Skill Doc Standard Note
- Agent-skill standard entrypoint is
SKILL.md. - This runbook is an operational convention (recommended), not a required skill file.
SKILL.mdis also a navigation convention for larger skill docs.
1) Confirm You Chose the Right Flow
- S2S (
account_credentials) for backend automation on your own account. - User OAuth (
authorization_code) for acting on behalf of users. - Device flow for browserless devices.
- Client credentials for chatbot-only scenarios.
Wrong flow choice causes scope and token errors later.
2) Confirm Endpoint Split
- Authorize URL:
https://zoom.us/oauth/authorize - Token URL:
https://zoom.us/oauth/token
If token requests return 404/HTML, verify you are not calling /oauth/token.
3) Confirm Redirect URI Exact Match
redirect_uriin token exchange must exactly match Marketplace config.- Match scheme, host, path, and trailing slash.
State Parameter Guardrail
- Always generate and verify
statefor user OAuth flows. - Expire state quickly and consume once.
- If callback has
codebut state is missing/invalid, reject and restart auth.
4) Confirm Scope and App Type Alignment
- Verify required scopes are added to app.
- Re-authorize after scope changes.
- Ensure app type supports requested behavior.
5) Confirm Token Lifecycle Handling
- Access token expires ~1 hour.
- Store latest refresh token after each refresh.
- Handle refresh failure with re-auth fallback.
Refresh Rotation Reminder
- Treat refresh tokens as rotating credentials.
- Persist new refresh token returned by refresh response.
- Using stale refresh tokens causes intermittent auth failures later.
6) Quick Probes
- Token endpoint returns JSON with
access_token. - API call to
/v2/users/mesucceeds with bearer token. - Redirect callback receives
codeand validstate.
Copy/Paste Validation Commands
Use these to verify OAuth plumbing in under a minute.
# 1) S2S token request
curl -X POST "https://zoom.us/oauth/token" \
-H "Authorization: Basic $(printf '%s:%s' "$ZOOM_CLIENT_ID" "$ZOOM_CLIENT_SECRET" | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=account_credentials&account_id=$ZOOM_ACCOUNT_ID"
# 2) User auth-code exchange
curl -X POST "https://zoom.us/oauth/token" \
-H "Authorization: Basic $(printf '%s:%s' "$ZOOM_CLIENT_ID" "$ZOOM_CLIENT_SECRET" | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=$ZOOM_AUTH_CODE&redirect_uri=$ZOOM_REDIRECT_URI"
# 3) Token health check
curl -X GET "https://api.zoom.us/v2/users/me" \
-H "Authorization: Bearer $ZOOM_ACCESS_TOKEN"7) Fast Decision Tree
- 4709 redirect mismatch -> fix exact redirect URI.
- 4702/4704 invalid client -> wrong client credentials or app.
- 4733/4734 code errors -> auth code expired/invalid, restart consent flow.
- Scopes missing -> add scopes + re-authorize.
8) Flow-to-App-Type Guardrail
- If using
account_credentials, app must support S2S flow. - If using
authorization_code, app and redirect configuration must support user consent. - If auth appears valid but API fails, verify app type, scope level, and account ownership assumptions.
Common Errors
See ../references/oauth-errors.md for complete error reference.
Common OAuth error codes: 4700-4741
For specific error details, consult the error reference documentation.
High-Frequency Endpoint Mistake
- Use
https://zoom.us/oauth/authorizefor user consent. - Use
https://zoom.us/oauth/tokenfor token exchange. - If token calls return HTML or 404, check that you are not calling
/oauth/token.
Redirect Uri Issues
See ../references/oauth-errors.md for complete error reference.
Common OAuth error codes: 4700-4741
For specific error details, consult the error reference documentation.
Scope Issues
See ../references/oauth-errors.md for complete error reference.
Common OAuth error codes: 4700-4741
For specific error details, consult the error reference documentation.
Token Issues
See ../references/oauth-errors.md for complete error reference.
Common OAuth error codes: 4700-4741
For specific error details, consult the error reference documentation.
Related skills
Forks & variants (1)
Zoom Oauth has 1 known copy in the catalog totaling 16 installs. They canonicalize to this original listing.
- zoom - 16 installs
How it compares
Use for OAuth grant selection before SDK embedding or REST API calls.
FAQ
What does zoom-oauth do?
Reference skill for Zoom authentication. Use after routing to an auth workflow when choosing app credentials, grant types, scopes, token refresh behavior, or debugging Zoom.
When should I use zoom-oauth?
User asks about zoom oauth or related SKILL.md workflows.
Is zoom-oauth safe to install?
Review the Security Audits panel on this page before installing in production.