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. Metered TURN credentials are auto-injected when the key's "Auto-inject TURN" toggle is on (default).
tokenProvider: async () => jwtBackend signs JWT with sk_secret_…Per-user peerId, per-user channels / permissions, custom peerMetadata. Metered TURN auto-injected when the sk_ key's toggle is on; your own metadata.iceServers in the JWT always wins if supplied.

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/realtime";

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.

Metered TURN — auto-injected for pk_live_ (zero config)

When you create a pk_live_ key in the dashboard, "Auto-inject TURN credentials" is enabled by default. WebRTC connections behind symmetric NAT just work — the Realtime Messaging service fetches your TURN credentials and injects them into the welcome message; the SDK applies them to every RTCPeerConnection automatically.

Requirements:

  • Your app needs an active TURN service (any tier, including free). Without a TURN addon, no credentials are injected and WebRTC falls back to STUN-only / host candidates (same as before this feature).
  • The key's "Auto-inject TURN" toggle is on. Flip it off per key in the Realtime Messaging → Keys dashboard if you'd rather supply your own.

You can verify TURN is being injected by checking the connected event on SignallingClient — its iceServers field is populated when injection is active.

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_secret_… signing secret. The browser fetches the JWT from your backend and passes it to the SDK.

import { MeteredPeer } from "@metered-ca/realtime";

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 your key pair (sk_id_…:sk_secret_…) 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.

TURN credentials — auto-injected by default

If your secret key has "Auto-inject TURN" enabled in the dashboard (default for new keys), you don't need to do anything — the Realtime Messaging service fetches your Metered TURN credentials and injects them into the welcome message. The SDK applies them to every RTCPeerConnection automatically.

Mint the JWT WITHOUT metadata.iceServers:

const token = jwt.sign({
sub: req.user.id,
channels: [`app_${req.appId}/room-*`],
permissions: ["publish", "subscribe", "presence", "send"],
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 } });

Requirements:

  • Your app needs an active TURN service (any tier, including free).
  • The sk_ key's "Auto-inject TURN" toggle is on.

Override with your own iceServers (advanced)

If you want to use a TURN service you operate yourself, or per-user TURN credentials, embed them in metadata.iceServersyour value always wins over the auto-injection:

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 } });

Presence wins over absence. An explicit empty array (metadata: { iceServers: [] }) is also honored as "I do not want any TURN" — auto-injection skips, the SDK runs STUN-only.

The SDK validates whatever lands in welcome.metadata.iceServers — 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 the signing secret (sk_secret_…) in client code. Never. 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. If any entry fails URL-scheme or size validation, the whole array is dropped (fail-closed) and the SDK falls back to STUN-only — check the SDK debug logs (ConsoleLogger) if TURN seems unreachable.

See also