WebRTC Signalling — No Backend (pk_ key only)
For single-page apps, static sites, prototypes, and pure-browser demos where you don't run a backend that can mint JWTs.
This guide uses raw WebSockets so it works from any stack. If you're on JS or TS, the SDK version of this guide is significantly shorter — peer.join(channel) handles the SDP / ICE exchange.
┌──────────────┐ ┌──────────────┐
│ │ 1. GET TURN ICE servers │ │
│ BROWSER │ ───────────────────────────────→ │ Metered TURN │
│ │ ←── { stun/turn URLs + creds } ─ │ REST API │
│ │ └──────────────┘
│ │
│ │ 2. WS connect ?key=pk_live_... ┌──────────────┐
│ │ ───────────────────────────────→ │ Metered │
│ │ ←── welcome (random peerId) ──── │ Realtime │
│ │ │ Messaging │
│ │ 3. subscribe to call channel + │ │
│ │ exchange SDP/ICE via `send` │ │
└──────────────┘ ───────────────────────────────→ └──────────────┘
▲
│ 4. RTCPeerConnection uses ICE servers from step 1
│ Media flows over Metered TURN
▼
┌──────────────┐
│ REMOTE │
│ PEER │
└──────────────┘
No JWT, no backend, no server-side mint step. Two browser-safe credentials do everything.
When this is the right path
| Use this no-backend path if… | Use the backend path if… |
|---|---|
| Static / Jamstack site, single-page app with no server | You have a backend that authenticates users (login system, sessions) |
| Pure-browser prototype, demo, or learning project | You want stable user identities (named participants in the call) |
| Anonymous or pseudonymous WebRTC use case | You want peerMetadata stamping (server-side identity on presence + direct) |
| Every user gets the same scope (same channelPatterns, same actions) | Different users get different scopes (admins vs guests, room-A vs room-B) |
| You're OK with peerIds randomly assigned by the server | You need to pick the peerId yourself (matches your userId) |
If you're not sure, the backend path is more flexible — start there if you have a backend at all. This page is for the case where you genuinely don't.
Constraints you trade for the simplicity
Pure pk_ connections are intentionally low-trust. The trade-offs:
| Constraint | What it means | Workaround |
|---|---|---|
| Server-assigned random peerId | The pk_ key is too low-trust to let the client pick a peerId. Server generates one at connect time (e.g. pk-conn-a1b2c3d4) and returns it in welcome.peerId. | Send identity inside your data payloads instead of relying on from (see "Carrying identity" below) |
No peerMetadata stamping | Without a JWT, there's no peerMetadata claim — presence events and direct messages won't carry the sender's username/avatar | Embed identity in the message body: { from: "Alice", avatar: "…", sdp: "…" }. The client renders from that, not fromMetadata. |
No metadata in welcome | The connection-private welcome bag is a JWT-claim pass-through; pk_ connections have no JWT to source from | Fetch TURN credentials yourself via the Metered TURN REST API (next section) — they don't need to ride in the JWT |
| Fixed scope from dashboard config | channelPatterns and actions are set on the key at create time. You can't narrow scope per-user (every connection inherits the full set). | Use a backend if per-user scoping matters. Otherwise pick the narrowest patterns that work for the whole app. |
Prerequisites
You need TWO browser-safe credentials, both created in the Metered dashboard:
- A Realtime Messaging
pk_live_…key — Dashboard → Realtime Messaging → Keys → Create key, type Publishable. Set:channelPatternsto whatever channels your app uses (e.g.["call-*"])actionsto["publish", "subscribe", "presence", "send"]allowedOriginsto your site's origin(s) —["https://your-site.com"]. This is critical for pk_ keys shipped to browsers; without it a leaked key works from any domain. See Authentication → Publishable keys.
- A Metered TURN credential's
apiKey— Dashboard → TURN Server → Credentials. The credentialapiKeyis browser-safe (distinct from your accountsecretKey); it's designed for direct frontend use with the Get Credential API. See Metered TURN → Get Credential.
Both can live in your frontend bundle. Treat them like any other public configuration constant.
Step 1 — Fetch TURN ICE servers from the browser
const METERED_APP_NAME = "your-app"; // your Metered dashboard app name
const METERED_TURN_API_KEY = "<credential-apiKey>"; // browser-safe; from the TURN credential
const METERED_REALTIME_PK = "pk_live_xxxxx..."; // browser-safe; from a Realtime Messaging publishable key
async function fetchIceServers() {
const resp = await fetch(
`https://${METERED_APP_NAME}.metered.live/api/v1/turn/credentials?apiKey=${METERED_TURN_API_KEY}`,
);
if (!resp.ok) throw new Error(`TURN credentials fetch failed: ${resp.status}`);
return resp.json();
}
The response is an array of ICE-server entries — STUN endpoints, TURN endpoints over UDP/TCP/TLS, with embedded username/credential strings — ready to feed straight into RTCPeerConnection.
A credential is good for ~24h. Cache the array in sessionStorage so reloads don't re-fetch:
async function fetchIceServersCached() {
const cached = sessionStorage.getItem("iceServers");
if (cached) return JSON.parse(cached);
const fresh = await fetchIceServers();
sessionStorage.setItem("iceServers", JSON.stringify(fresh));
return fresh;
}
Step 2 — Connect the WebSocket with the pk_ key
const iceServers = await fetchIceServersCached();
const ws = new WebSocket(
`wss://rms.metered.ca/v1?key=${encodeURIComponent(METERED_REALTIME_PK)}`,
);
let pc; // RTCPeerConnection — created after welcome
let localPeerId; // assigned by the server
// Local display identity — chosen by the user, embedded in every message
// payload so other peers know who you are (no peerMetadata available
// on pk_ connections).
const localDisplayName = prompt("Your name?") || "Anonymous";
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
if (msg.type === "welcome") {
localPeerId = msg.peerId;
console.log(`connected as ${localPeerId} (display: ${localDisplayName})`);
// Build the RTCPeerConnection with TURN servers from step 1
pc = new RTCPeerConnection({ iceServers });
wireUpRTCPeerConnection(pc);
// Join the call channel
ws.send(JSON.stringify({
type: "subscribe",
channel: "call-demo-room",
requestId: "sub-1",
}));
}
if (msg.type === "presence" && msg.channel === "call-demo-room") {
// A new peer joined. presence.joined entries carry NO metadata
// (no peerMetadata on pk_ connections), only peerIds.
for (const peer of msg.joined) {
if (peer.peerId !== localPeerId) {
console.log(`peer joined: ${peer.peerId}`);
// Initiate an SDP offer to them — embed our display name in
// the offer payload so they know who we are without backend.
initiateOfferTo(peer.peerId);
}
}
for (const peer of msg.left) {
console.log(`peer left: ${peer.peerId}`);
// Tear down the RTCPeerConnection for this peer
}
}
if (msg.type === "direct") {
// SDP / ICE from another peer. `msg.from` is their random peerId.
// `msg.data.fromName` is their self-reported display name (we put
// it in the payload because the protocol can't stamp it for us).
const { kind, sdp, candidate, fromName } = msg.data;
if (kind === "offer") handleOffer(msg.from, fromName, sdp);
if (kind === "answer") handleAnswer(msg.from, sdp);
if (kind === "ice") handleIceCandidate(msg.from, candidate);
}
};
ws.onclose = (evt) => {
console.log("ws closed", evt.code, evt.reason);
// 4001/4002 → fix key, reload
// 4010/4011/4012 → see protocol/close-codes.md
};
Step 3 — Exchange SDP and ICE candidates
async function initiateOfferTo(remotePeerId) {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({
type: "send",
to: remotePeerId,
data: {
kind: "offer",
sdp: offer.sdp,
fromName: localDisplayName, // ← our identity, embedded in the payload
},
requestId: `offer-${remotePeerId}`,
}));
}
async function handleOffer(remotePeerId, fromName, sdp) {
console.log(`offer from ${fromName} (${remotePeerId})`);
await pc.setRemoteDescription({ type: "offer", sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({
type: "send",
to: remotePeerId,
data: {
kind: "answer",
sdp: answer.sdp,
fromName: localDisplayName,
},
requestId: `answer-${remotePeerId}`,
}));
}
async function handleAnswer(_remotePeerId, sdp) {
await pc.setRemoteDescription({ type: "answer", sdp });
}
async function handleIceCandidate(_remotePeerId, candidate) {
await pc.addIceCandidate(candidate);
}
function wireUpRTCPeerConnection(pc) {
pc.onicecandidate = (evt) => {
if (!evt.candidate) return;
// For a 1:1 call, send candidates directly. For groups, broadcast
// on the call channel and the recipient filters by candidate.from.
ws.send(JSON.stringify({
type: "publish",
channel: "call-demo-room",
data: {
kind: "ice",
candidate: evt.candidate,
fromName: localDisplayName,
},
}));
};
pc.ontrack = (evt) => {
document.getElementById("remoteVideo").srcObject = evt.streams[0];
};
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => stream.getTracks().forEach((t) => pc.addTrack(t, stream)));
}
That's a working 1:1 WebRTC call, entirely from the browser.
Carrying identity without peerMetadata
The pattern above is the answer to "how does the no-backend path do user identity?" — embed it in the message payload instead of relying on the protocol's fromMetadata field.
Every outbound message that needs to render with an identity carries the fields the recipient will use:
ws.send(JSON.stringify({
type: "publish",
channel: "call-demo-room",
data: {
kind: "chat",
text: "hello",
fromName: localDisplayName, // user-supplied
fromAvatar: localAvatarUrl, // optional, e.g. gravatar
fromColor: localCursorColor, // optional, randomly assigned
},
}));
The recipient ignores msg.from for display purposes (it's just a random peerId) and renders from msg.data.fromName / fromAvatar instead.
Why this is OK at this trust level: the identity is self-asserted. Anyone with the same pk key could publish a message claiming to be "Alice." With a backend + JWT, the server stamps identity from the validated JWT claim, so "Alice" is verified. Pure pk trades that verification for backend-free simplicity. For prototypes, demos, or genuinely-anonymous use cases, that's fine. For anything where someone could impersonate another user maliciously, use the backend path.
Security checklist for pk_ keys in the browser
Before shipping a pk_ key to production:
-
allowedOriginsis set on the key to your site's origins (["https://your-site.com"]). Without this, anyone who finds the key in your JS bundle can use it from their own domain. -
channelPatternsis as narrow as practical — e.g.["call-*"]if your app only uses call channels, not["**"]. -
actionsonly includes what the frontend genuinely needs — typicallypublish/subscribe/presence/send. Don't grant a capability you don't use. - You're on a paid plan or have rate-limiting acceptable for your traffic — pk_ keys hit your account's connection / message quotas; a malicious user spamming connections still costs you (caps protect you, but you'll see traffic).
- TURN credential
apiKeyis also Origin-allowlisted — the TURN side has its own dashboard allowlist; use it.
The dashboard "Create key" form makes all of these visible; the defaults are conservative.
What happens if the pk_ key leaks
A leaked pk_ key with allowedOrigins properly set is only useful from your domains. An attacker who lifts the key from a page-source view can't use it from evil.com because the WebSocket upgrade rejects mismatched Origin headers.
If you suspect a key has leaked AND your allowedOrigins is empty / wrong:
- Revoke the key immediately — Dashboard → Realtime Messaging → Keys → revoke. Live connections close within seconds.
- Create a new key with a different
keyId— old key's keyId no longer works. - Update your frontend to ship the new key.
- Set
allowedOriginson the new key before deploying.
The revocation is propagated via the auth-invalidate pub/sub event — see Authentication → Key rotation.
Adding a "have a backend now" upgrade later
The no-backend path is a starting point. If your product later adds user accounts and you want stable peerIds + verified identity, the migration is small:
- Create a
sk_live_…key on the dashboard (separate from the pk_) - On your new backend, mint JWTs via
POST /v1/tokenscarrying the user's real ID +peerMetadata - The browser swaps
?key=pk_live_…for?token=<jwt>in the connect URL - The browser keeps reading TURN config — either by fetching from the TURN REST API as before, OR (recommended) by reading
welcome.metadata.iceServersso the backend ships ICE config inside the JWT (no extra round-trip — see the backend WebRTC guide)
Nothing about the WebRTC layer or the wire protocol changes — the SDP/ICE flow is identical. Only the auth path migrates.
Group calls
The example above shows a 1:1 call. For groups, you'd:
- Subscribe to a shared call channel as before
- On each
presence.joined, create an RTCPeerConnection per remote peer and exchange SDP/ICE viasend(1:1) to that specific peerId - Track a
Map<peerId, RTCPeerConnection>on the local side
Full-mesh works up to ~6 peers. For larger groups, Metered Global Cloud SFU is the media-plane primitive that lets every peer keep one connection to the SFU instead of N-to-N peer-to-peer.
See also
- WebRTC Signalling (with backend) — the JWT path with verified peerMetadata
- Authentication → Publishable keys — full pk_ behavior + Origin allowlist
- Metered TURN → Get Credential — full TURN REST API reference
- Presence & Metadata — why no-backend skips the metadata stamping story