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_…'));
Lock the key to your domain(s) / app via the dashboard's allowed-origins setting. Good for prototypes, internal tools, and apps where every user has the same channel scope.
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