Authentication
Two paths. Pick by where your code runs.
| Path | Where the key lives | What you get |
|---|---|---|
api_key="pk_live_…" | In your process / config | Connects 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). Publishable keys may not have the Send permission by default — see Get a key. |
token_provider=mint (an async def) | Your backend signs a JWT with sk_secret_… | Per-user peer_id, per-user channels / permissions, custom peer_metadata. Metered TURN auto-injected when the sk_ key's toggle is on; your own metadata.iceServers in the JWT always wins if supplied. Re-invoked on every reconnect, so refreshed tokens land automatically. |
Production apps with user accounts use the token_provider path. Servers you fully control, prototypes, IoT devices, and demos can use api_key directly.
You can mix and match — most apps use pk_live_ during development and switch to token_provider before launch.
Both paths work identically on MeteredPeer and SignallingClient — pass exactly one of api_key or token_provider to the constructor. Passing both, or neither, raises ValueError at construction.
Get a key
Sign up for a free account, then in the dashboard go to Realtime Messaging → Keys → Create key and choose the Publishable type.
Enable Send on the key. It's off by default for publishable keys, but the WebRTC layer uses it to exchange SDP and ICE candidates between peers — without it, peer.join() and peer.add_stream() succeed but the call never negotiates audio/video. Leave Subscribe, Publish, and Presence on.
Copy the pk_live_… value and pass it as api_key.
Pub/sub only? If you only
subscribe/publish(no WebRTC, noadd_stream), you don't need theSendaction — it's specifically the per-peer SDP/ICE exchange that requires it.MeteredPeeralways needs it; aSignallingClientdoing pure pub/sub does not.
Path 1 — api_key (publishable key)
The fastest way to ship something.
import asyncio
from metered_realtime import MeteredPeer
async def main() -> None:
async with MeteredPeer(api_key="pk_live_…") as peer:
await peer.join("room-42")
await asyncio.sleep(30)
asyncio.run(main())
That's it. The client 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-*matchesroom-42but notadmin-1.**matches everything (use carefully).actions—subscribe,publish,presence,send. Pick the subset you actually need. (MeteredPeerneedssendfor WebRTC negotiation — see Get a key.)
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
peer_id. The server assigns one (a UUID) on every connect, surfaced aspeer.peer_idafterjoin(). If you need stable per-user IDs, use a token provider. - Per-peer metadata (the field that lights up your presence UI with usernames and avatars, surfaced as
remote.metadataandData.sender_metadata). Requires a 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 connection 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.
- 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 reading the ice_servers field on the Connected event — it's a populated tuple[IceServerConfig, …] when injection is active, and None for a pk_ key without TURN.
from metered_realtime import SignallingClient, Connected
client = SignallingClient(api_key="pk_live_…")
@client.on(Connected)
def _(ev: Connected) -> None:
print("TURN injected:", ev.ice_servers is not None)
Path 2 — token_provider (JWT, server-side mint)
Your backend mints an HS256 JWT signed with your sk_secret_… signing secret. Your process fetches the JWT from your backend (or mints it in-process if your backend is the process) and hands the SDK a callable that returns it.
token_provider is an async zero-argument callable returning the JWT str (type Callable[[], Awaitable[str]] — the SDK awaits it). If your minting is synchronous, wrap it: token_provider=lambda: asyncio.to_thread(mint_sync) or a small async def that calls it. The transport never signs anything itself; producing the token is your backend's job.
import aiohttp
from metered_realtime import MeteredPeer
async def mint_token() -> str:
async with aiohttp.ClientSession() as session:
async with session.get("https://your-backend/api/mint-signalling-token") as r:
r.raise_for_status()
body = await r.json()
return body["token"]
peer = MeteredPeer(token_provider=mint_token)
await peer.join("room-42")
How the SDK uses token_provider
- Called on first connect. The 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
token_provider_timeout(default10.0seconds). If your mint endpoint hangs, the SDK gives up on that attempt and retries — this defends against a hanging backend pinning the WS handshake indefinitely. - Must return a non-empty string. Returning an empty string (or a non-
str) raisesValueErrorinside the SDK and is counted as a token-provider failure.
If the callable keeps raising, the SDK fires TokenProviderError after 3 consecutive failures.
Minting the JWT — server-side, with PyJWT
Any HS256-capable JWT library works. The SDK ships an optional auth extra that pulls in PyJWT:
pip install "metered-realtime[auth]"
Mint the token in your backend (a web handler, an auth service — never in client-distributed code):
# your backend — e.g. inside an HTTP handler that has already authenticated the user
import time
import jwt # PyJWT, from the metered-realtime[auth] extra
KEY_ID = os.environ["SIGNALLING_KEY_ID"] # sk_id_…
SECRET = os.environ["SIGNALLING_KEY_SECRET"] # sk_secret_…
def mint_signalling_token(user) -> str:
return jwt.encode(
{
"sub": user.id, # becomes the peer_id
"channels": [f"app_{user.app_id}/*"], # wildcard match
"permissions": ["publish", "subscribe", "presence", "send"],
"peerMetadata": {
"username": user.name,
"avatarUrl": user.avatar,
"role": user.role,
},
"exp": int(time.time()) + 3600, # 1 hour
},
SECRET,
algorithm="HS256",
headers={"kid": KEY_ID},
)
If you'd rather not embed JWT-signing in your backend, call the Metered REST API's tokens endpoint with your key pair (sk_id_…:sk_secret_…) as Bearer auth — the server mints the JWT for you from the claims you pass in the body. (See the REST API reference in the dashboard docs.)
JWT claims
| Claim | Required? | What it does |
|---|---|---|
sub | yes | Becomes the peer's id (peer.peer_id / remote.id). Up to 128 chars. Use your user ID. |
exp | yes | Unix seconds. Server rejects after this. Surfaces on the Connected event as expires_at. |
channels | yes | Array of wildcard patterns. The peer can interact with channels matching any pattern. |
permissions | yes | Subset of ["publish", "subscribe", "presence", "send"]. |
metadata | no | Up to 8 KB. Server returns it on the welcome. WebRTC apps put iceServers here. Surfaces as Connected.ice_servers. |
peerMetadata | no | Up to 4 KB. Server stamps it onto presence events and (optionally) onto broadcast messages. Used for usernames, avatars, etc. Surfaces as remote.metadata and Data.sender_metadata. |
Claim names are the wire-format names (
peerMetadata,iceServers) — they're what the server reads off the JWT, regardless of which language signs the token. They surface in the Python SDK under snake_case attributes (remote.metadata,Connected.ice_servers), but the JWT keys themselves stay camelCase.
metadata vs peerMetadata
metadata | peerMetadata | |
|---|---|---|
| Where it appears | Connected.ice_servers (your own connection only) | Other peers' remote.metadata, presence, + (opt-in) Data.sender_metadata |
| Size cap | 8 KB | 4 KB |
| Visibility | Only this peer | Other peers in subscribed channels |
| Typical use | TURN credentials (iceServers), feature flags, server hints | username, 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 peer connection automatically.
Mint the JWT WITHOUT metadata.iceServers:
jwt.encode(
{
"sub": user.id,
"channels": [f"app_{user.app_id}/room-*"],
"permissions": ["publish", "subscribe", "presence", "send"],
"peerMetadata": {"username": user.name, "avatarUrl": user.avatar},
"exp": int(time.time()) + 3600,
},
SECRET,
algorithm="HS256",
headers={"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.iceServers — your value always wins over the auto-injection:
turn_creds = await fetch_turn_credentials_from_your_backend(user.id)
jwt.encode(
{
"sub": user.id,
"channels": [f"app_{user.app_id}/room-*"],
"permissions": ["publish", "subscribe", "presence", "send"],
"metadata": {
"iceServers": turn_creds, # list of {"urls": …, "username"?: …, "credential"?: …}
},
"peerMetadata": {"username": user.name, "avatarUrl": user.avatar},
"exp": int(time.time()) + 3600,
},
SECRET,
algorithm="HS256",
headers={"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 the welcome's metadata.iceServers — scheme allowlist (stun: / stuns: / turn: / turns:) and size caps — and surfaces the validated set on Connected.ice_servers as a tuple[IceServerConfig, …] (each with urls: str | list[str], username: str | None, credential: str | None), passing it to every connection it constructs.
TURN rotation: since token_provider 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
| Pattern | Matches |
|---|---|
room-42 | exactly 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: [f"user-{user_id}/**", f"room-{user_id}-*"] — every user gets a private namespace.
When token_provider keeps failing
If your callable keeps raising — your backend is down, the user's session expired, a CDN cache is serving an old API — the SDK fires the TokenProviderError event after 3 consecutive failures. On SignallingClient it's informational; the SDK keeps retrying. On MeteredPeer the same condition surfaces as a FatalError (the peer layer consolidates terminal/auth-pipeline conditions onto one event).
from metered_realtime import SignallingClient, TokenProviderError
client = SignallingClient(token_provider=mint_token)
@client.on(TokenProviderError)
def _(ev: TokenProviderError) -> None:
log.warning("token_provider failed %dx: %r", ev.consecutive_failures, ev.err)
if ev.consecutive_failures >= 5:
prompt_user_to_log_in_again()
Don't close the client yourself from this handler — that throws away the existing connection, which may still be working for now with the old token. Let the SDK keep retrying. See Reconnect Best Practices → token_provider failures for the full pattern, including how it surfaces on MeteredPeer.
Common pitfalls
Embedding the signing secret (
sk_secret_…) in distributed code. Never. Anyone who has it can mint tokens as anyone. Keep it server-side. The Python SDK is built for servers and edge processes — but a "client" Python process you ship to third parties is still a client. Mint in a trusted backend.Caching the minted JWT until expiry. Your
token_provider()is called on every reconnect; if it returns a cached, expired-or-about-to-expire JWT, you'll thrash (close code 4002 → token_provider → stale token → 4002 again). Either mint a fresh JWT every call, or cache with a TTL well under the JWT'sexp(e.g. mint with 1 h expiry, cache for 50 min).Hanging mint endpoint. A slow mint will time out at
token_provider_timeout(10.0s default). Either speed up the endpoint or raise the timeout. A perpetually-slow mint will spin the client inreconnectingindefinitely.JWT without
kidheader. The server useskidto look up whichsk_to verify against. Ifkidis missing or wrong, you get close code 4001 (terminal). PyJWT takes it viaheaders={"kid": KEY_ID}— make sure you set it.Publishable key missing the
Sendpermission.peer.join()resolves, but the per-peer WebRTC negotiation never completes (no audio/video) because SDP/ICE exchange uses thesendaction. EnableSendon the key — see Get a key. On the server-error path this surfaces asaction_not_permitted.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).
Trusting
peerMetadatafor authorization. It's 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. Usechannels/permissions(server-enforced) for access decisions, not metadata. In the SDK this isremote.metadata/Data.sender_metadata— both documented as untrusted peer input.JWT
exptoo long. Tokens last forexp - iatseconds. A 24-hour token means a leaked token works for 24 hours. Mint short (1 hour or less); thetoken_providerrefresh handles long-running sessions for you.Misformatted
iceServers. Each entry is{"urls": str | list[str], "username"?: str, "credential"?: str}.urlsmust use one of the allowed schemes. If an entry fails URL-scheme or size validation, it's dropped and the SDK falls back to STUN-only — setlogger=StdlibLogger()and check the debug logs if TURN seems unreachable.
See also
SignallingClient—Connected.ice_servers,TokenProviderError, the auth optionsMeteredPeer—api_keyvstoken_provideron the room-level API;FatalError- Errors & Codes — 4001 / 4002 / 4003 close codes,
action_not_permitted, what to do about each - Reconnect Best Practices —
token_providerrefresh on reconnect, terminal close-code handling