HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/.codex/.tmp/plugins/plugins/zoom/skills/zoom-apps-sdk/concepts/security.md
# Security

## Overview

Zoom Apps must meet security requirements for Marketplace approval. This covers required headers, CSP configuration, cookie security, and token storage.

## Required OWASP Headers

Zoom's security review requires these HTTP headers on all responses:

| Header | Required Value | Purpose |
|--------|---------------|---------|
| `Strict-Transport-Security` | `max-age=31536000` | Force HTTPS for 1 year |
| `X-Content-Type-Options` | `nosniff` | Prevent MIME type sniffing |
| `Content-Security-Policy` | `frame-ancestors 'self' zoom.us *.zoom.us` | Allow Zoom to embed your app |
| `Referrer-Policy` | `same-origin` | Limit referrer info |
| `X-Frame-Options` | `ALLOW-FROM zoom.us` | Legacy frame control |

### Express Implementation

```javascript
app.use((req, res, next) => {
  res.setHeader('Strict-Transport-Security', 'max-age=31536000');
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('Content-Security-Policy',
    "frame-ancestors 'self' zoom.us *.zoom.us"
  );
  res.setHeader('Referrer-Policy', 'same-origin');
  next();
});
```

**Critical:** The `frame-ancestors` CSP directive is what allows Zoom's embedded browser to load your app. Without it, the browser blocks the frame.

## TLS Requirements

- HTTPS required for all endpoints (minimum TLS 1.2)
- HTTP redirects to HTTPS
- Valid SSL certificate (self-signed will fail)
- ngrok provides HTTPS automatically for local development

## Cookie Security

Zoom's embedded browser requires specific cookie settings:

```javascript
// Express cookie-session example
app.use(require('cookie-session')({
  name: 'session',
  keys: [process.env.SESSION_SECRET],
  maxAge: 24 * 60 * 60 * 1000, // 24 hours
  sameSite: 'none',  // REQUIRED - Zoom embeds your app cross-origin
  secure: true       // REQUIRED - SameSite=None requires Secure
}));
```

**Why `SameSite=None`?** Your app runs inside Zoom's embedded browser, which is a different origin. Without `SameSite=None`, the browser won't send cookies to your server, and sessions break silently.

## PKCE OAuth Security

All Zoom Apps OAuth flows should use PKCE (Proof Key for Code Exchange):

```javascript
const crypto = require('crypto');

// Generate PKCE pair
const verifier = crypto.randomBytes(32).toString('hex');
const challenge = crypto.createHash('sha256')
  .update(verifier)
  .digest('base64url');

// Store verifier in session (server-side)
req.session.codeVerifier = verifier;

// Send challenge to frontend (or include in OAuth redirect URL)
res.json({ codeChallenge: challenge, state: req.session.state });
```

PKCE prevents authorization code interception attacks. The `code_verifier` never leaves your server.

## Token Storage

**Never store tokens in the frontend (localStorage, sessionStorage, cookies).**

| Storage | When to Use | Security |
|---------|-------------|----------|
| **Redis** | Multi-instance servers, production | Fast, TTL support, scalable |
| **Encrypted session** | Single server, simple apps | Tied to server process |
| **Firestore/DynamoDB** | Serverless (Firebase/Lambda) | Persistent, managed |
| **Database (encrypted)** | Complex apps with user accounts | Full control, encrypted at rest |

```javascript
// Redis token storage example
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function storeTokens(zoomUserId, tokens) {
  await redis.set(
    `zoom:tokens:${zoomUserId}`,
    JSON.stringify(tokens),
    'EX', tokens.expires_in // Auto-expire with token
  );
}

async function getTokens(zoomUserId) {
  const data = await redis.get(`zoom:tokens:${zoomUserId}`);
  return data ? JSON.parse(data) : null;
}
```

## State Parameter (CSRF Protection)

Always validate the OAuth `state` parameter:

```javascript
const crypto = require('crypto');

// Generate state before OAuth redirect
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;

// Validate state on callback
app.get('/auth', (req, res) => {
  if (req.query.state !== req.session.oauthState) {
    return res.status(403).send('Invalid state - possible CSRF attack');
  }
  // Proceed with token exchange
});
```

## Data Access Layers

| Layer | What You Access | Authorization | Risk Level |
|-------|----------------|---------------|------------|
| **SDK (contextual)** | Meeting context, user info, UI controls | config() capabilities | Low - scoped to current context |
| **REST API (server-side)** | Full Zoom API (users, meetings, recordings) | OAuth access tokens | Medium - broader data access |
| **X-Zoom-App-Context** | User identity, meeting info | Client secret decryption | Low - read-only identity |

**Principle of least privilege:** Only request the OAuth scopes and SDK capabilities you actually need.

## Marketplace Security Review Checklist

- [ ] All OWASP headers set on every response
- [ ] HTTPS enforced (no HTTP endpoints)
- [ ] PKCE used for OAuth flows
- [ ] State parameter validated on OAuth callbacks
- [ ] Tokens stored server-side (never in frontend)
- [ ] Token refresh implemented (tokens expire in 1 hour)
- [ ] `SameSite=None; Secure` on cookies
- [ ] CSP allows `frame-ancestors zoom.us *.zoom.us`
- [ ] No sensitive data logged to console in production
- [ ] Environment variables for all secrets (no hardcoded credentials)

## Resources

- **Security docs**: https://developers.zoom.us/docs/zoom-apps/security/
- **OWASP headers**: https://developers.zoom.us/docs/zoom-apps/security/owasp/