Authentication
Two paths. Pick by where your code runs.
| Path | Where the key lives | What you get |
|---|---|---|
apiKey: "pk_live_…" | In your browser code | Connects directly. Server-side permissions baked into the key at dashboard time. |
tokenProvider: async () => jwt | Backend signs JWT with sk_live_… | Per-user peerId, per-user channels / permissions, custom peerMetadata, embedded TURN credentials |
Production apps with user accounts use the tokenProvider path. Prototypes, static sites, IoT devices, and demos can use apiKey directly.
You can mix and match — most apps use pk_live_ during development and switch to tokenProvider before launch.
Path 1 — apiKey (publishable key)
The fastest way to ship something.
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
await peer.join("room-42");
That's it. The browser passes the key to the server, the server checks it against your dashboard config, your peer is in.
What you set in the dashboard
When you create the pk_live_ in the dashboard, you set its permissions:
channels— wildcard patterns.room-*matchesroom-42but notadmin-1.**matches everything (use carefully).actions—subscribe,publish,presence,send. Pick the subset you actually need.allowedOrigins— array ofhttps://yourdomain.comentries. The server checks the WebSocket'sOriginheader.
Every peer that connects with this key gets the same permissions. Per-user scoping is not possible — that's what the JWT path is for.
What pk_live_ can't do
- Per-user
peerId. The server assigns one (a UUID) on every connect. If you need stable per-user IDs, use JWT. peerMetadata(the field that lights up your presence UI with usernames and avatars). Requires JWT.- Embedded TURN credentials. Required for WebRTC apps that want Metered TURN — TURN creds go in the JWT's
metadata.iceServers, see below. For pk_-only apps that need TURN, fetch creds from the TURN REST API client-side; see WebRTC No Backend.
Origin enforcement — what happens if it fails
The server checks the WS Origin header against the key's allowedOrigins. If it doesn't match, the WS is closed with 4001 (InvalidToken). The peer-side error message doesn't say "origin mismatch" specifically — it's a generic invalid-key error — so if you're seeing 4001 in a context where the key should be valid, check origins first.
Path 2 — tokenProvider (JWT, server-side mint)
Your backend mints an HS256 JWT signed with your sk_live_… secret. The browser fetches the JWT from your backend and passes it to the SDK.
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({
tokenProvider: async () => {
const r = await fetch("/api/mint-signalling-token");
if (!r.ok) throw new Error("mint failed");
const { token } = await r.json();
return token;
},
});
await peer.join("room-42");
How the SDK uses tokenProvider
- Called on first connect. Result is used as the auth token in the WS handshake.
- Called on every reconnect. Refreshed JWTs (new TURN creds, new permissions, new expiry) land automatically.
- Times out after
tokenProviderTimeoutMs(default 10 s). If your mint endpoint hangs, the SDK gives up on that attempt and tries again — defends against a hanging backend pinning the WS handshake indefinitely.
Minting the JWT — server-side
Any HS256-capable JWT library works. Node example:
// /api/mint-signalling-token
const jwt = require("jsonwebtoken");
const KEY_ID = process.env.SIGNALLING_KEY_ID; // sk_id_…
const SECRET = process.env.SIGNALLING_KEY_SECRET; // sk_secret_…
app.get("/api/mint-signalling-token", requireAuth, (req, res) => {
const token = jwt.sign(
{
sub: req.user.id, // becomes the peerId
channels: [`app_${req.appId}/*`], // wildcard match
permissions: ["publish", "subscribe", "presence", "send"],
peerMetadata: {
username: req.user.name,
avatarUrl: req.user.avatar,
role: req.user.role,
},
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
},
SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } },
);
res.json({ token });
});
If you'd rather not embed JWT-signing in your backend, call the REST API POST /v1/tokens with the sk_live_ as Bearer auth. The Metered server mints the JWT for you with the same claims you pass in the body.
JWT claims
| Claim | Required? | What it does |
|---|---|---|
sub | yes | Becomes the peer's peerId. Up to 128 chars. Use your user ID. |
exp | yes | Unix seconds. Server rejects after this. |
channels | yes | Array of wildcard patterns. Peer can interact with channels matching any pattern. |
permissions | yes | Subset of ["publish", "subscribe", "presence", "send"]. |
metadata | no | Up to 8 KB. Server returns it on the welcome. WebRTC apps put iceServers here. |
peerMetadata | no | Up to 4 KB. Server stamps it onto presence events and (optionally) onto broadcast messages. Used for usernames, avatars, etc. |
metadata vs peerMetadata
metadata | peerMetadata | |
|---|---|---|
| Where it appears | welcome.metadata (your own connection only) | Other peers' presence + (opt-in) broadcast messages |
| Size cap | 8 KB | 4 KB |
| Visibility | Only this peer | Other peers in subscribed channels |
| Typical use | TURN credentials (iceServers), feature flags, server hints | username, avatar, role, public profile |
Don't put secrets in peerMetadata — every peer in the channel sees it.
Embedded TURN credentials
For WebRTC apps using Metered TURN, the dashboard / TURN REST API generates a credential pair (username, credential). Embed them in metadata.iceServers:
const turnCreds = await fetchTurnCredentialsFromYourBackend(req.user.id);
const token = jwt.sign({
sub: req.user.id,
channels: [`app_${req.appId}/room-*`],
permissions: ["publish", "subscribe", "presence", "send"],
metadata: {
iceServers: turnCreds, // array of { urls, username?, credential? }
},
peerMetadata: { username: req.user.name, avatarUrl: req.user.avatar },
exp: Math.floor(Date.now() / 1000) + 3600,
}, SECRET, { algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } });
The SDK reads welcome.metadata.iceServers, validates the scheme allowlist (stun: / stuns: / turn: / turns:) and size caps (≤ 16 entries, ≤ 512 chars per URL, ≤ 1024 chars per credential), and passes the validated set to every RTCPeerConnection it constructs.
TURN rotation: since tokenProvider is called on every reconnect, JWT-rotated TURN creds propagate the next time the WS reconnects. To force immediate propagation, await peer.close() then construct a new MeteredPeer and rejoin.
Wildcards — channels claim
| Pattern | Matches |
|---|---|
room-42 | exactly room-42 |
room-* | any single-segment name starting with room-, like room-42, but not room/42/main |
app_xyz/** | any multi-segment path starting with app_xyz/ |
** | everything (server keys / admin tools only — use sparingly) |
For most apps, scoping by user is channels: [\user-${userId}/*`, `room-${userId}-`]` — every user gets a private namespace.
Common pitfalls
Embedding
sk_live_in client code. Never. Thesk_is the signing secret — anyone who has it can mint tokens as anyone. Keep it server-side.Caching the minted JWT until expiry. Your
tokenProvider()is called on every reconnect; if it returns a cached, expired-or-about-to-expire JWT, you'll thrash. Either mint a fresh JWT every call, or cache with a TTL well under the JWT'sexp(e.g., mint with 1 h expiry, cache for 50 min).Hanging mint endpoint. A slow
/api/mint-signalling-tokenwill time out attokenProviderTimeoutMs(10 s default). Either speed up the endpoint or raise the timeout. A perpetually-slow mint will spin the client inreconnectingindefinitely.JWT without
kidheader. The server useskidto look up whichsk_to verify against. Ifkidis missing or wrong, you get 4001. Most JWT libraries supportheader: { kid: KEY_ID }— make sure you set it.Wide-open `channels: [""]`.** This grants the peer access to every channel. Use the narrowest pattern that works for the app's flow (per-user prefix, per-room prefix, per-tenant prefix).
Trusting
peerMetadatafor authorization.peerMetadatais visible to every peer in the channel and stamped by your backend, but a peer with a leaked JWT could keep using it past your intended scope. Usechannels/permissions(server-enforced) for access decisions, not metadata.JWT
exptoo long. Tokens last forexp - iatseconds. A 24-hour token means a leaked token works for 24 hours. Mint short (1 hour or less); thetokenProviderrefresh handles long-running sessions for you.Misformatted
iceServers. Each entry is{ urls: string | string[], username?: string, credential?: string }.urlsmust use one of the allowed schemes. Misformatted entries are silently dropped by the validator — check the SDK debug logs (ConsoleLogger) if TURN seems unreachable.
See also
- Wire Format → Authentication — the underlying wire-level handshake
- REST API → Tokens — mint JWTs via REST instead of signing locally
- Presence & Chat guide — putting
peerMetadatato use - WebRTC Video Call guide — JWT with embedded TURN creds end-to-end