Setting the file. One moment.
Subchapter 127.7
concepts/token-lifecycle.mdMarkdown15 KBView on GitHub
Understanding how Zoom OAuth tokens are created, expire, refresh, and revoke is critical for building reliable integrations.
Authorization: Bearer {access_token} header| 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 |
┌────────────────────────────────────────────────────┐
│ │
│ Token Request │
│ │ │
│ v │
│ [ Access Token Valid ] │
│ │
│ ├───────────────────── 1 hour ──────────────────┤ │
│ │
│ Token │
│ Expires │
│ │ │
│ v │
│ Request New Token ────────────────> [ New Access Token Valid ]
│ │
└────────────────────────────────────────────────────┘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:
┌────────────────────────────────────────────────────────────────────┐
│ │
│ 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) │ │
│ │
└────────────────────────────────────────────────────────────────────┘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:
CRITICAL: Zoom rotates refresh tokens on every 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 INVALID// ❌ 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
});Lifetime: 5 minutes
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-authorizeapp.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:
User re-authorizes your app:
User removes your app:
Explicit revocation:
https://zoom.us/oauth/revoke endpointRefresh token expires (lifetime varies):
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:
| 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 |
// ✅ 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);// ✅ 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!
}
}// ✅ 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!// ✅ 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]);// ✅ Detect revoked tokens and prompt re-auth
if (error.code === 4741) {
await deleteUserTokens(userId);
res.redirect('/auth'); // Re-authorize
}Cause: Server clock is incorrect
Solution:
# Sync server time
sudo ntpdate -s time.nist.govCause: Using old refresh token after it was rotated
Solution:
Cause: Code expired (5 minutes passed) or already used
Solution:
Cause: User re-authorized your app or removed it
Solution: