Skip to main content

Authentication

Two paths. Pick by where your code runs.

PathWhere the key livesWhat you get
api_key="pk_live_…"In your process / configConnects 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, no add_stream), you don't need the Send action — it's specifically the per-peer SDP/ICE exchange that requires it. MeteredPeer always needs it; a SignallingClient doing 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-* matches room-42 but not admin-1. ** matches everything (use carefully).
  • actionssubscribe, publish, presence, send. Pick the subset you actually need. (MeteredPeer needs send for 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 as peer.peer_id after join(). 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.metadata and Data.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 (default 10.0 seconds). 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) raises ValueError inside 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

ClaimRequired?What it does
subyesBecomes the peer's id (peer.peer_id / remote.id). Up to 128 chars. Use your user ID.
expyesUnix seconds. Server rejects after this. Surfaces on the Connected event as expires_at.
channelsyesArray of wildcard patterns. The peer can interact with channels matching any pattern.
permissionsyesSubset of ["publish", "subscribe", "presence", "send"].
metadatanoUp to 8 KB. Server returns it on the welcome. WebRTC apps put iceServers here. Surfaces as Connected.ice_servers.
peerMetadatanoUp 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

metadatapeerMetadata
Where it appearsConnected.ice_servers (your own connection only)Other peers' remote.metadata, presence, + (opt-in) Data.sender_metadata
Size cap8 KB4 KB
VisibilityOnly this peerOther peers in subscribed channels
Typical useTURN credentials (iceServers), feature flags, server hintsusername, 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.iceServersyour 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

PatternMatches
room-42exactly 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

  1. 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.

  2. 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's exp (e.g. mint with 1 h expiry, cache for 50 min).

  3. Hanging mint endpoint. A slow mint will time out at token_provider_timeout (10.0 s default). Either speed up the endpoint or raise the timeout. A perpetually-slow mint will spin the client in reconnecting indefinitely.

  4. JWT without kid header. The server uses kid to look up which sk_ to verify against. If kid is missing or wrong, you get close code 4001 (terminal). PyJWT takes it via headers={"kid": KEY_ID} — make sure you set it.

  5. Publishable key missing the Send permission. peer.join() resolves, but the per-peer WebRTC negotiation never completes (no audio/video) because SDP/ICE exchange uses the send action. Enable Send on the key — see Get a key. On the server-error path this surfaces as action_not_permitted.

  6. 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).

  7. Trusting peerMetadata for 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. Use channels / permissions (server-enforced) for access decisions, not metadata. In the SDK this is remote.metadata / Data.sender_metadata — both documented as untrusted peer input.

  8. JWT exp too long. Tokens last for exp - iat seconds. A 24-hour token means a leaked token works for 24 hours. Mint short (1 hour or less); the token_provider refresh handles long-running sessions for you.

  9. Misformatted iceServers. Each entry is {"urls": str | list[str], "username"?: str, "credential"?: str}. urls must 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 — set logger=StdlibLogger() and check the debug logs if TURN seems unreachable.

See also

  • SignallingClientConnected.ice_servers, TokenProviderError, the auth options
  • MeteredPeerapi_key vs token_provider on the room-level API; FatalError
  • Errors & Codes — 4001 / 4002 / 4003 close codes, action_not_permitted, what to do about each
  • Reconnect Best Practicestoken_provider refresh on reconnect, terminal close-code handling