STUN and TURN

STUN and TURN

Managed STUN discovery and TURN relay for WebRTC and ICE workloads.


rstream provides a managed STUN and TURN service for WebRTC applications. It complements the signaling layer described in Signaling: signaling coordinates sessions and peer state, while STUN and TURN make ICE candidate discovery and relay fallback work across NAT, firewall, and network-policy boundaries.

In a typical deployment, rstream tunnels expose the signaling API or WebSocket endpoint, and the managed STUN/TURN service covers ICE candidate discovery and relay connectivity for the same tunnel project.

TURN credentials are always short-lived and project-scoped.

This page focuses on TURN itself: what the service exposes, how credentials are issued, and how local derivation works. For SDK entrypoints, see JS SDK and Go SDK.

Supported protocols

The rstream STUN/TURN service accepts STUN binding requests on UDP and TCP, with IPv4 and IPv6 listeners enabled. TURN UDP relay allocations are available over UDP, TCP, TLS, and DTLS on both IPv4 and IPv6. TURN TCP relay allocations are currently available on IPv6 when the TURN control connection uses TCP or TLS.

CapabilityStatus
STUN binding over UDPAvailable on IPv4 and IPv6
STUN binding over TCPAvailable on IPv4 and IPv6
TURN UDP relay allocationsAvailable over UDP, TCP, TLS, and DTLS on IPv4 and IPv6
TURN TCP relay allocationsAvailable over TCP and TLS on IPv6
TURN TCP relay allocations over IPv4Not currently available
TURN TCP relay allocations over UDP or DTLS controlNot currently available

References:

STUN RFC 8489, TURN RFC 8656, ICE RFC 8445.

WebRTC ICE configuration

Each TURN credential response returns urls, username, and credential. Those fields map directly to a WebRTC iceServers entry.

const iceServers = [
  {
    urls: turn.urls,
    username: turn.username,
    credential: turn.credential,
  },
];

In practice, signaling and TURN are usually used together: signaling exchanges offers, answers, and ICE candidates, while STUN/TURN makes those candidates usable across real network boundaries.

Hosted usage and limits

Managed STUN/TURN is included in hosted tunnel plans at no additional cost. Relay quota is tracked separately from the regular tunnel bandwidth quota and varies by plan. Community Edition deployments do not include managed STUN/TURN.

The dashboard keeps TURN usage separate from tunnel bandwidth for the current billing period and includes a dedicated TURN view for credential issuance, monthly usage, relay traffic, concurrent allocations, transports, allocation types, address families, and client countries. Current hosted limits remain described on the pricing page.

Credential generation paths

rstream supports managed issuance through either the Control plane or Engine API, local PAT derivation, and local APP derivation. Certificate-only clients issue credentials through the Engine API. They need no token or application secret.

For production backends, the recommended path is to use the JS SDK or Go SDK rather than reimplement the TURN derivation logic manually.

All paths produce short-lived credentials. Treat username and credential as opaque values. Local PAT/APP derivation retains the v1 wire format; managed issuers use v2 identities that retain the original credential or user reference. Existing v1 credentials remain accepted during migration.

The TURN runtime rejects expired usernames and usernames whose remaining lifetime is greater than 3600 seconds. SDK helpers default to 600 seconds for locally derived credentials. Control plane issuance defaults to 600 seconds for project credentials and accepts an optional ttlSeconds value between 1 and 3600 seconds. Evaluation/demo credentials default to 3600 seconds.

For pat and app modes, the TURN runtime verifies the credential directly against the stored rstream credential and user. The credential owner must still have active access to the target project, stored credential permissions must either be unrestricted or include turn.relay.allocate, and stored resources.tunnels must allow the target project or workspace when boundaries are present.

Platform API

The Control plane API fits systems that already call rstream during session setup. It returns a fresh TURN credential for the target project.

The same operation is available through POST /api/projects/tunnels/<project-id>/turn-server/credentials and POST /api/projects/tunnels/resolve/<project-endpoint>/turn-server/credentials. Both routes require turn.credentials.create. The Engine equivalent is POST /api/turn-server/credentials, authenticated with a token or certificate on its dedicated mTLS authority. The same permission, active project membership, resource scope, and expiry checks apply to both issuers.

const res = await fetch(
  "https://rstream.io/api/projects/tunnels/resolve/<project-endpoint>/turn-server/credentials",
  {
    method: "POST",
    headers: {
      Authorization: "Bearer <auth-token>",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ ttlSeconds: 600 }),
  },
);
 
const turn = await res.json();

Both APIs accept an empty body or { "ttlSeconds": 600 } and return { username, credential, urls, ttl } with Cache-Control: no-store. ttl is the effective remaining lifetime in seconds, capped by the authenticated proof, credential, certificate, and user expiry. Invalid parameters return 400, invalid proofs 401, insufficient rights or scope 403, missing projects 404, inactive infrastructure 409, throttling 429, and unavailable backends or signing configuration 503.

Issued credentials need turn.credentials.create; they do not additionally require turn.relay.allocate. Local PAT/APP derivation uses turn.relay.allocate. The relay rechecks the original credential, user, project membership, resources, and quotas. Revoking the original credential prevents further relay authorization.

Automatic SDK selection

Go and JavaScript use eligible local PAT/APP derivation when the relay metadata is already known. Otherwise they try the Control plane, then the configured Engine only for network unavailability or HTTP 502/503/504. They do not fall back after authorization, TLS verification, malformed responses, throttling, or caller cancellation. Certificate clients go directly to the discovered Engine API. The default issuance budget is ten seconds, with a five-second Control plane attempt before fallback. Explicit api, engine, pat, and app modes remain available.

Certificate-only applications

Keep one Go SDK client alive across TURN renewals:

client, err := config.NewClientFromEnv()
if err != nil {
  log.Fatal(err)
}
defer client.Close()
 
turn, err := client.CreateTURNCredentials(ctx, rstream.CreateTURNCredentialsOptions{
  TTL: 10 * time.Minute,
})
if err != nil {
  log.Fatal(err)
}

Use the existing Engine address and certificate context. The SDK discovers and caches the mTLS API in memory; it does not change the context file. No CLI credential-generation command is needed. Renew before turn.TTL expires.

JS SDK

The JS SDK supports both managed issuance and local derivation.

Managed issuance

Use @rstreamlabs/rstream when your backend talks to the Control plane API. This works with either a bearer token or an application clientId / clientSecret.

import { RstreamClient } from "@rstreamlabs/rstream";
 
const client = new RstreamClient({
  credentials: { token: process.env.RSTREAM_AUTHENTICATION_TOKEN! },
});
 
const turn = await client.tunnels.projects.createTurnCredentialsByEndpoint(
  "project-endpoint",
  { ttlSeconds: 600 },
);

The same Control plane API path also works from application credentials:

import { RstreamClient } from "@rstreamlabs/rstream";
 
const client = new RstreamClient({
  credentials: {
    clientId: process.env.RSTREAM_CLIENT_ID!,
    clientSecret: process.env.RSTREAM_CLIENT_SECRET!,
  },
});
 
const turn = await client.tunnels.projects.createTurnCredentialsByEndpoint(
  "project-endpoint",
  { ttlSeconds: 600 },
);

Local derivation

Use @rstreamlabs/tunnels when your backend already has a PAT or an application credential pair and wants to derive TURN credentials locally. The relay domain is the hostname published in ICE URLs. The realm is the authentication scope used for key derivation. They are often equal, but a regional relay attached to a global edge network uses its regional hostname and the global realm. Managed project resolution supplies both values.

From a PAT:

import { RstreamTunnelsClient } from "@rstreamlabs/tunnels";
 
const client = new RstreamTunnelsClient({
  credentials: { token: process.env.RSTREAM_AUTHENTICATION_TOKEN! },
  projectEndpoint: "project-endpoint",
});
 
const turn = await client.turn.createCredentials({
  mode: "pat",
  ttlSeconds: 600,
});

From clientId and clientSecret:

import { RstreamTunnelsClient } from "@rstreamlabs/tunnels";
 
const client = new RstreamTunnelsClient({
  credentials: {
    clientId: process.env.RSTREAM_CLIENT_ID!,
    clientSecret: process.env.RSTREAM_CLIENT_SECRET!,
  },
  projectEndpoint: "project-endpoint",
});
 
const turn = await client.turn.createCredentials({ ttlSeconds: 600 });

Go SDK

The Go SDK exposes a single helper for TURN credential creation and supports the same operational paths as the CLI/config bridge.

package main
 
import (
  "context"
  "encoding/json"
  "log"
  "os"
 
  "github.com/rstreamlabs/rstream-go/config"
)
 
func main() {
  turn, err := config.CreateTURNCredentialsFromEnv(context.Background())
  if err != nil {
    log.Fatal(err)
  }
  if err := json.NewEncoder(os.Stdout).Encode(turn); err != nil {
    log.Fatal(err)
  }
}

With environment variables:

RSTREAM_AUTHENTICATION_TOKEN='<auth-token-or-pat>' \
RSTREAM_PROJECT_ENDPOINT='<project-endpoint>' \
go run ./examples/turn-credentials

Local derivation reference

The snippets below show the underlying derivation logic without relying on the SDKs. They are useful as protocol references, but the recommended operational path remains the JS SDK or Go SDK.

PAT-backed derivation

PAT-backed derivation fits automation flows that already store a personal access token. The PAT remains the authority boundary: any holder of that PAT can derive TURN credentials from it.

import crypto from "crypto";
 
function base64UrlDecode(input: string): Buffer {
  const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
  const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
  return Buffer.from(padded, "base64");
}
 
export function createTurnCredentialFromPat({
  token,
  projectEndpoint,
  turnDomain,
  turnRealm,
  ttlSeconds = 600,
}: {
  token: string;
  projectEndpoint: string;
  turnDomain: string;
  turnRealm: string;
  ttlSeconds?: number;
}) {
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > 3600) {
    throw new Error("TURN TTL must be an integer between 1 and 3600 seconds");
  }
 
  const parts = token.split(".");
  if (parts.length !== 3) {
    throw new Error("Invalid JWT format");
  }
 
  const payload = JSON.parse(base64UrlDecode(parts[1]).toString("utf8"));
  const tokenEndpoint = payload.token_endpoint;
  if (!tokenEndpoint || typeof tokenEndpoint !== "string") {
    throw new Error("Invalid JWT format");
  }
 
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const username = `v1:${exp}:pat:${projectEndpoint}:${tokenEndpoint}`;
  const tokenHash = crypto.createHash("sha256").update(token, "utf8").digest();
  const key = Buffer.from(
    crypto.hkdfSync(
      "sha256",
      tokenHash,
      Buffer.from(turnRealm, "utf8"),
      Buffer.from("turn-pat-v1", "utf8"),
      32,
    ),
  );
  const credential = crypto
    .createHmac("sha256", key)
    .update(username, "utf8")
    .digest("base64");
 
  return {
    credential,
    ttl: ttlSeconds,
    urls: [
      `turn:${turnDomain}:3478?transport=udp`,
      `turn:${turnDomain}:3478?transport=tcp`,
      `turns:${turnDomain}:5349?transport=udp`,
      `turns:${turnDomain}:5349?transport=tcp`,
    ],
    username,
  };
}

APP-backed derivation

APP-backed derivation fits backends that hold a client_id and a client_secret and should derive TURN credentials locally without relying on a personal credential.

import { createAPPTURNCredentials } from "@rstreamlabs/tunnels";
 
export async function createTurnCredentialFromApp({
  clientId,
  clientSecretHex,
  projectEndpoint,
  turnDomain,
  turnRealm,
  keyringBaseUrl = "https://rstream.io",
  ttlSeconds = 600,
}: {
  clientId: string;
  clientSecretHex: string;
  projectEndpoint: string;
  turnDomain: string;
  turnRealm: string;
  keyringBaseUrl?: string;
  ttlSeconds?: number;
}) {
  return await createAPPTURNCredentials({
    clientId,
    clientSecret: clientSecretHex,
    keyringBaseUrl,
    projectEndpoint,
    ttlSeconds,
    turnDomain,
    turnRealm,
  });
}

The SDK accepts a bounded hexadecimal DER keyring response, verifies that it contains an EC public key on the same curve as the application private key, and performs ECDH only after validation. Redirects are rejected so the configured keyring origin remains the trust boundary. A TURN realm publishes one reviewed public key; every node serving that realm uses the matching private key.

Transport security

Deployments that require encrypted TURN transport only can expose turns: endpoints and disable plain TURN listeners at the runtime layer. When plain TURN transport remains enabled, TURN control traffic is not protected by the TURN transport itself. In WebRTC deployments, media and data channels still remain encrypted by the WebRTC security model once the session is established.