Skip to main content

Authentication

Two paths. Pick by where your code runs.

PathWhere the key livesWhat you get
apiKey: "pk_live_…"In your browser codeConnects directly. Server-side permissions baked into the key at dashboard time.
tokenProvider: async () => jwtBackend 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-* matches room-42 but not admin-1. ** matches everything (use carefully).
  • actionssubscribe, publish, presence, send. Pick the subset you actually need.
  • allowedOrigins — array of https://yourdomain.com entries. The server checks the WebSocket's Origin header.

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

ClaimRequired?What it does
subyesBecomes the peer's peerId. Up to 128 chars. Use your user ID.
expyesUnix seconds. Server rejects after this.
channelsyesArray of wildcard patterns. Peer can interact with channels matching any pattern.
permissionsyesSubset of ["publish", "subscribe", "presence", "send"].
metadatanoUp to 8 KB. Server returns it on the welcome. WebRTC apps put iceServers here.
peerMetadatanoUp to 4 KB. Server stamps it onto presence events and (optionally) onto broadcast messages. Used for usernames, avatars, etc.

metadata vs peerMetadata

metadatapeerMetadata
Where it appearswelcome.metadata (your own connection only)Other peers' presence + (opt-in) broadcast messages
Size cap8 KB4 KB
VisibilityOnly this peerOther peers in subscribed channels
Typical useTURN credentials (iceServers), feature flags, server hintsusername, 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

PatternMatches
room-42exactly 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

  1. Embedding sk_live_ in client code. Never. The sk_ is the signing secret — anyone who has it can mint tokens as anyone. Keep it server-side.

  2. 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's exp (e.g., mint with 1 h expiry, cache for 50 min).

  3. Hanging mint endpoint. A slow /api/mint-signalling-token will time out at tokenProviderTimeoutMs (10 s default). Either speed up the endpoint or raise the timeout. A perpetually-slow mint will spin the client in reconnecting indefinitely.

  4. JWT without kid header. The server uses kid to look up which sk_ to verify against. If kid is missing or wrong, you get 4001. Most JWT libraries support header: { kid: KEY_ID } — make sure you set it.

  5. 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).

  6. Trusting peerMetadata for authorization. peerMetadata is 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. Use channels / permissions (server-enforced) for access decisions, not metadata.

  7. JWT exp too long. Tokens last for exp - iat seconds. A 24-hour token means a leaked token works for 24 hours. Mint short (1 hour or less); the tokenProvider refresh handles long-running sessions for you.

  8. Misformatted iceServers. Each entry is { urls: string | string[], username?: string, credential?: string }. urls must 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