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 tickSendwhen creating the key. Without it,join()succeeds and presence fires, butonPeerJoined → onStreamAddednever fires because noRTCPeerConnectionever negotiates. (This is the single most common "my call doesn't connect" cause.)pk_+ pub/sub only (SignallingClient, no WebRTC) →Sendis needed only if you callclient.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) — soremote.metadataisnulland presence entries have nometadata. iceServerscome 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.
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
| Claim | Required | What it does |
|---|---|---|
sub | yes | Becomes the peer's peerId. Up to 128 chars. Use your user id. |
exp | yes | Unix seconds. ≤ 24h. |
channels | yes | Wildcard patterns: * = one path segment, ** = any number. |
permissions | yes | Subset of ["publish", "subscribe", "presence", "send"]. Include send for WebRTC. |
metadata | no | Up to 8 KB. Returned on the welcome (ConnectedEvent.iceServers reads metadata.iceServers). |
peerMetadata | no | Up 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.metadataon eachRemotePeerpresenceentries'metadataMeteredData.senderMetadataononData(directs always; broadcasts when you joined withincludeSenderMetadata: 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
- WebRTC Video Call — uses the
tokenProviderpath end-to-end - Presence & Chat — where
peerMetadatashines MeteredPeerreference ·SignallingClientreference