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

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 three TURN credential paths: managed issuance through the control-plane API, local derivation from a PAT, and local derivation from an application client_id and client_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 three paths issue short-lived TURN credentials only. Long-lived TURN username/password pairs are not part of the service. Usernames follow the shape v1:<exp>:<mode>:<project-endpoint>:<subject-ref>.

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. This API expects a short-lived auth token and is not the PAT-based local-derivation path.

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

JS SDK

The JS SDK supports both managed issuance and local derivation.

Managed issuance

Use @rstreamlabs/rstream when your backend talks to the managed control plane. 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",
);

The same control-plane 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",
);

Local derivation

Use @rstreamlabs/tunnels when your backend already has a PAT or an application credential pair and wants to derive TURN credentials locally.

From a PAT:

import { createTURNCredentials } from "@rstreamlabs/tunnels";
 
const turn = await createTURNCredentials({
  credentials: { token: process.env.RSTREAM_AUTHENTICATION_TOKEN! },
  projectEndpoint: "project-endpoint",
  clusterDomain: "cluster.example.rstream.test",
});

From clientId and clientSecret:

import { createTURNCredentials } from "@rstreamlabs/tunnels";
 
const turn = await createTURNCredentials({
  credentials: {
    clientId: process.env.RSTREAM_CLIENT_ID!,
    clientSecret: process.env.RSTREAM_CLIENT_SECRET!,
  },
  projectEndpoint: "project-endpoint",
  clusterDomain: "cluster.example.rstream.test",
});

If you already use a managed tunnels client, the same helper is available as a resource:

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();

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,
  clusterDomain,
  ttlSeconds = 86400,
}: {
  token: string;
  projectEndpoint: string;
  clusterDomain: string;
  ttlSeconds?: number;
}) {
  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(clusterDomain, "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:${clusterDomain}:3478?transport=udp`,
      `turn:${clusterDomain}:3478?transport=tcp`,
      `turns:${clusterDomain}:5349?transport=udp`,
      `turns:${clusterDomain}: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 crypto from "crypto";
 
export async function createTurnCredentialFromApp({
  clientId,
  clientSecretHex,
  projectEndpoint,
  clusterDomain,
  keyringBaseUrl = "https://rstream.io",
  ttlSeconds = 86400,
}: {
  clientId: string;
  clientSecretHex: string;
  projectEndpoint: string;
  clusterDomain: string;
  keyringBaseUrl?: string;
  ttlSeconds?: number;
}) {
  const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
  const username = `v1:${exp}:app:${projectEndpoint}:${clientId}`;
 
  const clientPrivateKey = crypto.createPrivateKey({
    key: Buffer.from(clientSecretHex, "hex"),
    format: "der",
    type: "pkcs8",
  });
 
  const keyringRes = await fetch(
    `${keyringBaseUrl}/keyrings/turn/${clusterDomain}.spki.der.hex`,
  );
  if (!keyringRes.ok) {
    throw new Error("Failed to load TURN keyring");
  }
 
  const serverPublicKeyHex = (await keyringRes.text()).trim();
  const serverPublicKey = crypto.createPublicKey({
    key: Buffer.from(serverPublicKeyHex, "hex"),
    format: "der",
    type: "spki",
  });
 
  const sharedSecret = crypto.diffieHellman({
    privateKey: clientPrivateKey,
    publicKey: serverPublicKey,
  });
 
  const key = Buffer.from(
    crypto.hkdfSync(
      "sha256",
      sharedSecret,
      Buffer.from(clusterDomain, "utf8"),
      Buffer.from("turn-app-v1", "utf8"),
      32,
    ),
  );
 
  const credential = crypto
    .createHmac("sha256", key)
    .update(username, "utf8")
    .digest("base64");
 
  return {
    credential,
    ttl: ttlSeconds,
    urls: [
      `turn:${clusterDomain}:3478?transport=udp`,
      `turn:${clusterDomain}:3478?transport=tcp`,
      `turns:${clusterDomain}:5349?transport=udp`,
      `turns:${clusterDomain}:5349?transport=tcp`,
    ],
    username,
  };
}

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.