#Authenticating admins and integrations
AddedA device API has two very different kinds of caller. People (an admin at a laptop, an installer with a tablet) log in through the web UI with a username and password, stay for twenty minutes and leave. Programs (a room-booking panel, a monitoring script, a partner's control system, the vendor's cloud management service) call the API around the clock, never type a password and should only be able to do the one job they were set up for. Most security bugs in device APIs come from treating the two the same: giving the monitoring script the admin password, or making the admin's browser carry a token that never expires.
The mental model is a building's front desk. Visitors sign in with ID and get a day badge (a session: short-lived, held by the browser, cancelled when they leave). Contractors get a key card programmed for specific doors (an API token with scopes: long-lived, issued by an admin, opens only what it says, and can be cancelled at the desk without changing anyone else's card). Nobody gets the master key, and the desk keeps a list of every card it issued.
That maps onto four mechanisms you'll be asked to compare:
| Mechanism | Who uses it | What the client holds | Revocation | Notes for devices |
|---|---|---|---|---|
| Session cookie | people in the web UI | a random session id in an HttpOnly; Secure; SameSite cookie | delete the session on the device | needs CSRF protection (see Securing the on-device web UI); idle and absolute timeouts |
| API token with scopes | scripts, partners, cloud services | an opaque random token, shown once | delete the token record | stored on the device as a hash; scopes limit the damage if it leaks |
| HTTP Basic / Digest | legacy tools, curl | the password itself (Basic) or a hash-based response (Digest) | change the password | Basic is only acceptable inside TLS; Digest doesn't replace TLS |
| JWT (signed, self-contained) | multi-service back ends | a signed token the server doesn't need to look up | hard: valid until exp | rarely worth it on one device, which can look its own sessions up for free |
The last row is the one interviewers poke at. JWTs earn their keep when many stateless servers must check a token without sharing a database (see Sessions vs JWTs on the Node site). A device is one server with its own state: looking up a session or a token hash in local memory costs nothing, and opaque credentials can be revoked instantly. A JWT on a device mostly adds a signing key to protect and a revocation problem to solve. The exception is a token issued by someone else, for example the management cloud signing short-lived tokens that every device in the fleet can verify offline; then a signed token is exactly right.
Poly's public REST reference shows the session pattern on a real device. In the Poly VideoOS REST API Reference Guide (for VideoOS 4.4.0, last updated August 2025), a client sends POST /rest/session with { "user", "password" }, gets back a session object (sessionId, role: "ADMIN", clientType, the client's address) plus a loginStatus with failedLogins and a loginResult such as "NOLOCKOUT", and a failed login returns 403. The guide says administrator credentials are required before any REST command, that the REST API "is never disabled", and every command it lists has the access level Admin. It doesn't describe scoped tokens; that's the general pattern below, not a description of Poly's product.
Here's a device API with both kinds of caller: an admin who logs in and gets a session cookie, and a monitoring integration that gets a token allowed to read status and nothing else.
import express from "express";
import { scryptSync, randomBytes, timingSafeEqual, createHash } from "node:crypto";
// The device stores a salted scrypt hash of the admin password, never the password itself.
const salt = randomBytes(16);
const admin = { user: "admin", salt, hash: scryptSync("plum-falcon-orbit-42", salt, 32) };
const sessions = new Map(); // session id -> { user, scopes, expires }
const tokens = new Map(); // token id -> { name, scopes, hash }
const ALL_SCOPES = ["status:read", "settings:read", "settings:write", "tokens:manage"];
const sha256 = (s) => createHash("sha256").update(s).digest();
const app = express();
app.use(express.json());
app.post("/api/session", (req, res) => {
const { user, password } = req.body ?? {};
const ok = user === admin.user && timingSafeEqual(scryptSync(String(password), admin.salt, 32), admin.hash);
if (!ok) return res.status(401).json({ error: "invalid_credentials" });
const id = randomBytes(32).toString("base64url");
sessions.set(id, { user, scopes: ALL_SCOPES, expires: Date.now() + 15 * 60_000 });
res.set("Set-Cookie", `sid=${id}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900`).status(204).end();
});
app.use((req, res, next) => {
const sid = /(?:^|;\s*)sid=([^;]+)/.exec(req.get("cookie") ?? "")?.[1];
const s = sid && sessions.get(sid);
if (s && s.expires > Date.now()) return (req.who = { kind: "session", ...s }), next();
const m = /^Bearer (dak_([0-9a-f]{8})_[\w-]{43})$/.exec(req.get("authorization") ?? "");
const t = m && tokens.get(m[2]);
if (t && timingSafeEqual(sha256(m[1]), t.hash)) return (req.who = { kind: "token", ...t }), next();
res.set("WWW-Authenticate", m ? 'Bearer realm="device", error="invalid_token"' : 'Bearer realm="device"');
res.status(401).json({ error: "unauthenticated" });
});
const need = (scope) => (req, res, next) =>
req.who.scopes.includes(scope)
? next()
: res.set("WWW-Authenticate", `Bearer realm="device", error="insufficient_scope", scope="${scope}"`)
.status(403).json({ error: "insufficient_scope", need: scope });
const settings = { roomName: "Huddle 4B", autoAnswer: false };
app.get("/api/status", need("status:read"), (req, res) => res.json({ inCall: false, by: req.who.kind }));
app.get("/api/settings", need("settings:read"), (req, res) => res.json(settings));
app.patch("/api/settings", need("settings:write"), (req, res) => res.json(Object.assign(settings, req.body)));
app.post("/api/tokens", need("tokens:manage"), (req, res) => {
const { name, scopes = [] } = req.body ?? {};
if (req.who.kind !== "session") return res.status(403).json({ error: "session_required" });
if (!scopes.every((s) => ALL_SCOPES.includes(s) && s !== "tokens:manage")) return res.status(422).json({ error: "bad_scopes" });
const id = randomBytes(4).toString("hex");
const token = `dak_${id}_${randomBytes(32).toString("base64url")}`; // shown once, stored only as a hash
tokens.set(id, { name, scopes, hash: sha256(token) });
res.status(201).json({ id, token, scopes });
});
app.delete("/api/tokens/:id", need("tokens:manage"), (req, res) => res.status(tokens.delete(req.params.id) ? 204 : 404).end());
const server = app.listen(0, "127.0.0.1");
await new Promise((r) => server.once("listening", r));
const base = `http://127.0.0.1:${server.address().port}`;
async function call(label, method, path, { body, cookie, token } = {}) {
const headers = { "content-type": "application/json" };
if (cookie) headers.cookie = cookie;
if (token) headers.authorization = `Bearer ${token}`;
const r = await fetch(base + path, { method, headers, body: body && JSON.stringify(body) });
const text = await r.text();
const extra = r.headers.get("www-authenticate") ?? (r.headers.get("set-cookie") ?? "").replace(/sid=[^;]+/, "sid=<random>");
console.log(`${label.padEnd(22)} ${method.padEnd(6)} ${path.replace(/[0-9a-f]{8}$/, "{id}").padEnd(16)} → ${r.status} ${text.replace(/dak_\w{8}_[\w-]{43}/, "dak_…").replace(/"id":"\w+"/, '"id":"…"')}`);
if (extra) console.log(`${"".padEnd(48)}${extra}`);
return { r, text };
}
await call("no credentials", "GET", "/api/status");
await call("wrong password", "POST", "/api/session", { body: { user: "admin", password: "admin" } });
const login = await call("admin logs in", "POST", "/api/session", { body: { user: "admin", password: "plum-falcon-orbit-42" } });
const cookie = login.r.headers.get("set-cookie").split(";")[0];
await call("admin (cookie)", "PATCH", "/api/settings", { cookie, body: { autoAnswer: true } });
const made = JSON.parse((await call("admin mints a token", "POST", "/api/tokens", { cookie, body: { name: "room-monitor", scopes: ["status:read"] } })).text);
await call("monitor (token)", "GET", "/api/status", { token: made.token });
await call("monitor (token)", "PATCH", "/api/settings", { token: made.token, body: { autoAnswer: false } });
await call("admin revokes it", "DELETE", `/api/tokens/${made.id}`, { cookie });
await call("monitor (revoked)", "GET", "/api/status", { token: made.token });
server.close();no credentials GET /api/status → 401 {"error":"unauthenticated"}
Bearer realm="device"
wrong password POST /api/session → 401 {"error":"invalid_credentials"}
admin logs in POST /api/session → 204
sid=<random>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900
admin (cookie) PATCH /api/settings → 200 {"roomName":"Huddle 4B","autoAnswer":true}
admin mints a token POST /api/tokens → 201 {"id":"…","token":"dak_…","scopes":["status:read"]}
monitor (token) GET /api/status → 200 {"inCall":false,"by":"token"}
monitor (token) PATCH /api/settings → 403 {"error":"insufficient_scope","need":"settings:write"}
Bearer realm="device", error="insufficient_scope", scope="settings:write"
admin revokes it DELETE /api/tokens/{id} → 204
monitor (revoked) GET /api/status → 401 {"error":"unauthenticated"}
Bearer realm="device", error="invalid_token"- The device never stores the password. At start-up it keeps
scryptSync(password, salt, 32), a deliberately slow, salted hash;POST /api/sessionhashes the submitted password the same way and compares withtimingSafeEqual, so the comparison takes the same time whether the first byte or the last one differs. The wrong password"admin"gets401 invalid_credentials. (Password hashing in depth: Hashing passwords with bcrypt.) - The admin's login creates a session: 32 random bytes as the id, kept in the
sessionsmap with an expiry 15 minutes out, and sent assid=<random>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900.HttpOnlykeeps it away from page JavaScript,Securekeeps it off plain HTTP,SameSite=Strictstops the browser attaching it to requests started by other sites. With that cookie thePATCH /api/settingssucceeds, because a session carries every scope. - The admin mints a token for the monitoring script with
scopes: ["status:read"]. The tokendak_<8 hex id>_<43 random chars>is returned once; the device stores only its SHA-256 and the scopes. The id part lets the device find the record without scanning, and the prefixdak_makes leaked tokens easy to spot in logs and code (secret scanners look for exactly such prefixes). A plain SHA-256 is fine here, unlike for passwords, because the token is 256 random bits: there's nothing to guess. - The token can read status but not change settings.
GET /api/statusreturns{"inCall":false,"by":"token"}.PATCH /api/settingsgets403withWWW-Authenticate: Bearer realm="device", error="insufficient_scope", scope="settings:write", the error format RFC 6750 (OAuth 2.0 Bearer Token Usage) defines: the caller is authenticated, so401would be wrong, and the header tells an integrator which scope to ask for. - Revocation is one delete. After
DELETE /api/tokens/{id}the very next call with the same token gets401anderror="invalid_token", while the admin's session and any other tokens carry on. Compare a shared admin password baked into five integrations: rotating it breaks all five at once, so in practice nobody rotates it. - Two rules in
POST /api/tokensare easy to miss: only a session can mint tokens (req.who.kind !== "session"gets403), and no token can be grantedtokens:manage(an unknown or forbidden scope gets422). Without them, one leaked token could mint itself a more powerful one.
Many device APIs still accept HTTP Basic, because every tool from curl to a 15-year-old control system speaks it. Basic (RFC 7617) sends user:password in Base64 on every request:
// What a Basic auth header carries, and what anyone who sees it can do with it.
const header = "Basic " + Buffer.from("admin:plum-falcon-orbit-42").toString("base64");
console.log(header);
console.log(Buffer.from(header.slice(6), "base64").toString()); // no key needed: it's an encoding, not encryptionBasic YWRtaW46cGx1bS1mYWxjb24tb3JiaXQtNDI=
admin:plum-falcon-orbit-42Buffer.from("admin:plum-falcon-orbit-42").toString("base64")producesYWRtaW46cGx1bS1mYWxjb24tb3JiaXQtNDI=, and that's the whole header afterBasic.- Decoding it needs no key at all: the second line prints the password. Base64 is an encoding (a way to fit bytes into a header), not encryption.
- So Basic over plain HTTP hands the admin password to anyone on the same network segment, on every request, not just at login. Over TLS it's acceptable, with two remaining costs: the password itself (not a revocable token) sits in every script that calls the device, and browsers cache Basic credentials and send them automatically, which makes them ambient credentials that need CSRF protection just like cookies.
- Digest (RFC 7616) sends a hash of the password with a server nonce instead of the password, but it doesn't encrypt the request or response, older clients only do MD5, and the device must keep something password-equivalent to check it. It's a legacy compatibility option, not a substitute for TLS.
- Opaque, random, prefixed. At least 128 bits of randomness (the model uses 256), a recognisable prefix (
dak_) so leaks are found by secret scanners, and a short public id so the device can look the record up. - Stored as a hash, shown once. If someone copies the device's config database, they get hashes, not working tokens. The UI shows the full token only at creation; after that it shows the name, scopes, creation time, last use and the id.
- Scoped to a job. Name scopes after what integrations do, not after endpoints:
status:read,calls:control,settings:read,settings:write,firmware:update. A room panel needscalls:controlandstatus:read; a monitoring tool needsstatus:read; only an admin session needstokens:manage. - Expiring and revocable. Let the admin pick an expiry (90 days, a year, never with a warning) and revoke any token by id. Record
lastUsedAtand the source address so the UI can show "this token hasn't been used in 200 days". - Bound to the device. A token minted on one device is useless on another; that limits a leak to one room, unlike a fleet-wide shared password.
- Never in URLs. Tokens go in the
Authorizationheader. Query strings end up in proxy logs, browser history andRefererheaders. - Audited. Creating, using for a privileged action, and revoking a token are all audit events (see Least privilege and audit logs).
- Local accounts live on the device (the model's
admin). They must work with no network services at all, because the person fixing a broken network needs to log in. Keep at least one local admin even when you add directory login. - Directory accounts (LDAP or Active Directory) let IT use their normal accounts and groups. Poly's Video Mode Administrator Guide 4.7.0 (HP, last updated July 2025) describes this: the device is pointed at an AD server, one AD group maps to admin access and one to user access, and the system "can map only one Active Directory group to a given role". The same guide notes that the local user account is disabled when external authentication is on, while the local admin account stays usable: the break-glass account.
- Management-cloud identity. When a fleet service manages the device, the service authenticates to the device with its own credential (a client certificate or a provisioned token), and people authenticate to the service with SSO and MFA. The device then only needs to trust one well-protected caller instead of hundreds of admins.
- Separate passwords per channel are a reasonable hardening step: the same Poly guide has a Room Password for the local touch interface and an optional separate Remote Access Password for the web interface and command-line API (SSH or Telnet), with "Use Room Password for Remote Access" enabled by default.
A device has a few hundred kilobytes of memory to spare for sessions and one admin who forgets to log out. Three limits keep sessions safe and cheap:
- Idle timeout (no requests for N minutes ends the session) and absolute timeout (a session ends after N hours regardless). Poly's admin guide exposes both under Security > Access as "Idle Session Timeout in Minutes" and "Max Session Timeout in Minutes".
- A cap on concurrent sessions ("Maximum Number of Active Sessions" in the same guide), so a script that logs in on every call can't exhaust memory. Return
429or503with a clear error when the cap is reached, and make scripts use tokens instead of logging in repeatedly. - Rotate the session id at login and on privilege change, and delete the session on logout and when the password changes. A session created before a password change shouldn't survive it.
A monitoring token with scopes ["status:read"] calls POST /api/system/reboot, which needs system:control. Later, an admin revokes that token and the script calls GET /api/status again. What does the device return each time, and what should the WWW-Authenticate header say?
Show answer
First call: 403 Forbidden with WWW-Authenticate: Bearer realm="device", error="insufficient_scope", scope="system:control". Second call: 401 Unauthorized with WWW-Authenticate: Bearer realm="device", error="invalid_token".
- On the first call the token is genuine and current: the device knows exactly which integration is calling. Authentication succeeded, authorisation failed, so it's
403, not401. - RFC 6750 defines
insufficient_scopefor this case, and the optionalscopeattribute tells the integrator what to request. Re-sending the same token won't help, so a well-behaved client stops and reports it. - After revocation, the device can't find a matching token hash: the caller is no longer authenticated, so it's
401.error="invalid_token"(also RFC 6750) says the token is expired, revoked or malformed, which tells the client to get a new one rather than retry. - In the model above you can see both:
PATCH /api/settingswith the monitor token gives the403line, and the revoked token gives the401line.