Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Authentication

zm-api uses bearer JWTs. No cookies are involved anywhere.

Getting tokens

POST /api/v3/auth/login
Content-Type: application/json

{ "username": "admin", "password": "…" }
{
  "token_type": "Bearer",
  "access_token": "eyJ…",
  "refresh_token": "eyJ…",
  "expire_in": 600
}

Send the access token on every other request:

Authorization: Bearer eyJ…

Lifetimes and refresh

Access tokens last 10 minutes, refresh tokens 1 hour. They are signed with separate RSA key pairs, deliberately — a leaked access key cannot mint refresh tokens. Tokens also carry a typ claim, so a refresh token cannot be presented as an access token or vice versa.

POST /api/v3/auth/refresh
Content-Type: application/json

{ "token": "<refresh_token>" }

Knowing when a token expires

Don’t decode the JWT client-side. GET /api/v3/me returns the user plus issued_at, expires_at, and token_type:

{
  "user": { "username": "operator", "system": "None", "monitors": "Edit", … },
  "token_type": "access",
  "issued_at": 1755820000,
  "expires_at": 1755820600
}

Logging out

GET /api/v3/auth/logout
Authorization: Bearer <access_token>

This is a real server-side revocation, not a client-side token discard: it raises the user’s TokenMinExpiry floor, so every outstanding token for that account stops working immediately, including ones issued to other devices.

Media URLs

<img> and <video> elements cannot set headers, so the snapshot route also accepts the token as a query parameter:

GET /api/v3/monitors/1/snapshot?token=<JWT>

This is the only place that is accepted. It puts a credential in a URL, where it can land in proxy logs and browser history — prefer the header wherever the client controls the request.

Rate limiting

The authentication endpoints have their own limiter, on by default at roughly one request per two seconds with a burst of 10. A login retry loop will start getting 429s. The prod profile additionally enables a global per-IP limiter that is off in base.toml, so a client that fans out many parallel requests on page load can behave differently in production than in development.

Behind a reverse proxy, set APP_SERVER__MIDDLEWARE__TRUST_PROXY_HEADERS=true or every client shares the proxy’s single bucket. Leave it false anywhere zm-api is reachable directly — the headers are attacker-controlled there.