Skip to main content

Authentication

Two paths. Pick by where your code runs and whether you need per-user scoping. Both work identically for MeteredPeer and SignallingClient.

Path 1 — apiKey (publishable key)​

A pk_live_… key embedded directly in your app. Fixed scope set in the dashboard. No backend required.

final peer = MeteredPeer(MeteredPeerOptions(apiKey: 'pk_live_…'));

The key can't be locked to your domain(s) or app — there is no allowed-origins setting, so anyone who extracts it from your app can use it with the same scope. Keep its channels and actions narrow. Good for prototypes and apps where every user has the same channel scope and the data isn't sensitive.

pk_ keys and the Send permission​

When you create a publishable key, Subscribe / Publish / Presence are on by default but Send is OFF. The dashboard shows an amber warning: because the key ships inside your app, Send would let anyone holding it direct-message any peer.

MeteredPeer's WebRTC layer carries SDP + ICE between peers over the wire protocol's send operation. So:

  • pk_ + WebRTC → you must tick Send when creating the key. Without it, join() succeeds and presence fires, but onPeerJoined → onStreamAdded never fires because no RTCPeerConnection ever negotiates. (This is the single most common "my call doesn't connect" cause.)
  • pk_ + pub/sub only (SignallingClient, no WebRTC) → Send is needed only if you call client.send(peerId, …) directly.

Limitations vs the JWT path​

  • Server assigns a random UUID as peerId (no stable per-user identity).
  • No peerMetadata (no JWT to carry it) — so remote.metadata is null and presence entries have no metadata.
  • iceServers come from auto-injection (when the key has "Auto-inject TURN" on, default) — no separate TURN fetch.

Path 2 — tokenProvider (sk_-minted JWT)​

Your backend mints an HS256 JWT signed with sk_secret_…. The SDK calls your provider on first connect AND every reconnect (auto-refresh).

final peer = MeteredPeer(MeteredPeerOptions(
tokenProvider: () async {
final r = await http.get(Uri.parse('https://your.app/api/mint-realtime-token'));
if (r.statusCode != 200) throw Exception('mint failed');
return (jsonDecode(r.body) as Map)['token'] as String;
},
));

tokenProvider is a Future<String> Function(). The SDK never inspects the token — it goes into the connection's auth query param.

Return a FRESH token each call

tokenProvider runs on every reconnect. Your endpoint must return a fresh JWT each time (or cache with a TTL well under the JWT's exp). A stale JWT triggers close code 4002 → re-mint → stale-JWT loop. The SDK caps how long it waits for your provider with tokenProviderTimeoutMs (default 10 s); after repeated failures it fires onTokenProviderError (informational) and keeps retrying.

Minting JWTs server-side (Node)​

const jwt = require("jsonwebtoken");

app.get("/api/mint-realtime-token", requireAuth, async (req, res) => {
const turnCreds = await fetchTurnForUser(req.user.id); // optional, WebRTC only

const token = jwt.sign(
{
sub: req.user.id, // becomes peerId
channels: [`app_${req.appId}/call-*`], // wildcard scope
permissions: ["publish", "subscribe", "presence", "send"],
metadata: { iceServers: turnCreds }, // welcome-only (TURN)
peerMetadata: { username: req.user.name, avatarUrl: req.user.avatar }, // visible to peers
exp: Math.floor(Date.now() / 1000) + 3600,
},
process.env.SK_SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: process.env.SK_ID } },
);
res.json({ token });
});

JWT claims reference​

ClaimRequiredWhat it does
subyesBecomes the peer's peerId. Up to 128 chars. Use your user id.
expyesUnix seconds. ≤ 24h.
channelsyesWildcard patterns: * = one path segment, ** = any number.
permissionsyesSubset of ["publish", "subscribe", "presence", "send"]. Include send for WebRTC.
metadatanoUp to 8 KB. Returned on the welcome (ConnectedEvent.iceServers reads metadata.iceServers).
peerMetadatanoUp to 4 KB. Stamped onto presence + directs + opt-in channel messages. Surfaces as remote.metadata and MeteredData.senderMetadata.

Or skip the signing — REST API​

const { token } = await fetch("https://rms.metered.ca/v1/tokens", {
method: "POST",
headers: { Authorization: `Bearer ${SK_SECRET}`, "Content-Type": "application/json" },
body: JSON.stringify({ peerId, channels, permissions, expiresInSec: 3600,
metadata: { iceServers }, peerMetadata: { username, avatarUrl } }),
}).then(r => r.json());

TURN credentials — auto-injected by default​

For WebRTC, peers need TURN to connect across restrictive networks (cellular, corporate firewalls). With "Auto-inject TURN" enabled on your key (default), the Realtime service fetches your TURN credentials and injects them into the welcome — you don't fetch or embed them.

To override with your own TURN (or per-user creds), set metadata: { iceServers: [...] } in the JWT. Your value wins over auto-injection. The SDK validates iceServers against an allowlist (stun:/stuns:/turn:/turns: schemes, size caps) before use; malformed entries are dropped fail-closed.

ConnectedEvent.iceServers (a List<IceServerConfig>?) is what the SDK received — null for pk_ keys.

peerMetadata — server-verified identity​

Unlike per-track StreamMetadata (sender-stamped, untrusted), peerMetadata is carried in the JWT your backend signed, so its origin is trustworthy. It surfaces as:

  • remote.metadata on each RemotePeer
  • presence entries' metadata
  • MeteredData.senderMetadata on onData (directs always; broadcasts when you joined with includeSenderMetadata: true)
peer.onPeerJoined.listen((remote) {
showNameTag(remote.id, remote.metadata?['username'] ?? 'Anonymous');
});

Still don't use it for authorization — a peer with a leaked JWT keeps using it. Use the JWT's server-enforced channels claim for access control.

See also​