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 carries session coordination and peer state. STUN and TURN make ICE candidate discovery and relay fallback work across NAT and firewall boundaries.

Project-bound credentials carry the tunnel project endpoint inside the TURN username so usage accounting, quotas, and policy remain attached to the correct project. Short-lived evaluation credentials use the same service and omit that project binding.

Supported protocols

The managed 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. TURN TCP relay allocations are not currently available on IPv4, or when the TURN control connection uses UDP or DTLS.

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 credential response returns urls, username, and credential. Those fields can be mapped directly to a WebRTC iceServers entry.

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

In a typical WebRTC deployment, signaling exchanges offers, answers, and ICE candidates. STUN and TURN make those candidates usable in the presence of NAT, firewall, and network-policy constraints. Both layers are usually required in practice.

Transport security

Deployments that require encrypted TURN transport only can expose turns: endpoints and disable plain UDP and TCP 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, which reduces the exposure to TURN transport metadata and control traffic.

Credential models

rstream supports three credential models because TURN credentials are not created for the same purpose in every system. Some applications already have authenticated project context and can ask the platform for a fresh TURN credential. Others need local derivation inside an existing backend path, either from a personal access token or from a dedicated application credential pair.

All three models produce short-lived TURN credentials. The managed service does not expose a long-lived username and password model for TURN authentication. Credentials are generated on demand through one of the three flows described below and are expected to expire. Project-bound usernames follow the shape v1:<exp>:<mode>:<project-endpoint>:<subject-ref>.

API-issued credentials

The API-issued model is the hosted-platform path for authenticated users and services that already know which tunnels project will own the session. The backend resolves the project, selects the correct cluster host, sets the TURN realm to that cluster domain, and returns a short-lived TURN username and credential pair.

This model fits applications that already call the rstream control plane before opening a WebRTC session.

The TypeScript example below expects an authenticated API token and a target project identifier. It requests a fresh TURN credential from the hosted platform and maps the response to a WebRTC ICE server entry.

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

The shell example expects RSTREAM_TOKEN and PROJECT_ID to be defined in the environment. It performs the same control-plane request and returns the TURN payload as JSON.

export RSTREAM_TOKEN='<auth-token>'
export PROJECT_ID='<project-id>'
 
curl -sS "https://rstream.io/api/projects/tunnels/${PROJECT_ID}/turn-server/credentials" \
  -X POST \
  -H "authorization: Bearer ${RSTREAM_TOKEN}" | jq

PAT-derived credentials

The PAT-derived model is intended for server-side automation that already owns a long-lived personal access token. No control-plane round trip is required on the TURN credential path. The backend extracts token_endpoint from the JWT payload, combines it with the target project endpoint, derives the TURN secret locally, and passes the resulting TURN credential to the WebRTC client.

This model fits automation flows where a stable personal credential already exists and where TURN credentials must be created close to the application session. Any holder of that PAT can derive TURN credentials from it, so the PAT itself remains the authority boundary.

The TypeScript example below expects a PAT, the target tunnel project endpoint, and a TURN realm in the form <cluster-domain>.

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,
  realm,
  ttlSeconds = 3600,
}: {
  token: string;
  projectEndpoint: string;
  realm: 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(realm, "utf8"),
      Buffer.from("turn-pat-v1", "utf8"),
      32,
    ),
  );
  const credential = crypto
    .createHmac("sha256", key)
    .update(username, "utf8")
    .digest("base64");
  return { username, credential, ttl: ttlSeconds };
}

The shell example expects RSTREAM_TOKEN, TURN_PROJECT_ENDPOINT, and TURN_REALM. TURN_TTL is optional and defaults to 86400.

export RSTREAM_TOKEN='<personal-access-token>'
export TURN_PROJECT_ENDPOINT='<project-endpoint>'
export TURN_REALM='<cluster-domain>'
 
node <<'EOF' | jq
(async () => {
  const crypto = require('crypto');
  const base64UrlDecode = (input) => {
    const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
    return Buffer.from(normalized + '='.repeat((4 - (normalized.length % 4)) % 4), 'base64');
  };
  const token = process.env.RSTREAM_TOKEN;
  if (!token) throw new Error('Please define RSTREAM_TOKEN');
  const ttl = Number(process.env.TURN_TTL || 86400);
  const projectEndpoint = process.env.TURN_PROJECT_ENDPOINT;
  if (!projectEndpoint) throw new Error('Please define TURN_PROJECT_ENDPOINT');
  const realm = process.env.TURN_REALM;
  if (!realm) throw new Error('Please define TURN_REALM');
  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) + ttl;
  const username = 'v1:' + exp + ':pat:' + projectEndpoint + ':' + tokenEndpoint;
  const tokenHash = crypto.createHash('sha256').update(token, 'utf8').digest();
  const key = crypto.hkdfSync('sha256', tokenHash, Buffer.from(realm, 'utf8'), Buffer.from('turn-pat-v1', 'utf8'), 32);
  const credential = crypto.createHmac('sha256', key).update(username, 'utf8').digest('base64');
  process.stdout.write(JSON.stringify({ username, credential, ttl, realm, project_endpoint: projectEndpoint }));
})().catch((e) => { console.error(e.message); process.exit(1); });
EOF

APP-derived credentials

The APP-derived model is intended for backends that hold a client_id and a client_secret and need to create TURN credentials locally without carrying a personal token. The backend combines the application key material with the TURN server public key for the target cluster, derives a shared secret through ECDH, and then derives the TURN credential from that shared secret.

This model fits service-to-service integrations, embedded products, and automation paths that should be isolated from personal credentials. It also gives the integrating backend tighter control over TURN issuance. App auth tokens minted for downstream clients do not allow TURN credential derivation. That capability remains limited to possession of the client_id and client_secret pair.

The TypeScript example below expects an application credential pair, the target tunnel project endpoint, and the TURN realm in the form <cluster-domain>.

import crypto from "crypto";
 
export async function createTurnCredentialFromApp({
  clientId,
  clientSecretHex,
  projectEndpoint,
  realm,
  keyringBaseUrl = "https://rstream.io",
  ttlSeconds = 3600,
}: {
  clientId: string;
  clientSecretHex: string;
  projectEndpoint: string;
  realm: 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/${realm}.spki.der.hex`,
  );
  if (!keyringRes.ok) throw new Error("Failed to load TURN keyring");
  const serverPublicKey = crypto.createPublicKey({
    key: Buffer.from((await keyringRes.text()).trim(), "hex"),
    format: "der",
    type: "spki",
  });
  const shared = crypto.diffieHellman({
    privateKey: clientPrivateKey,
    publicKey: serverPublicKey,
  });
  const key = Buffer.from(
    crypto.hkdfSync(
      "sha256",
      shared,
      Buffer.from(realm, "utf8"),
      Buffer.from("turn-app-v1", "utf8"),
      32,
    ),
  );
  const credential = crypto
    .createHmac("sha256", key)
    .update(username, "utf8")
    .digest("base64");
  return { username, credential, ttl: ttlSeconds };
}

The shell example expects CLIENT_ID, CLIENT_SECRET, TURN_PROJECT_ENDPOINT, and TURN_REALM. TURN_SERVER_PUB_HEX is optional and can be used to skip the remote keyring fetch.

export CLIENT_ID='<client-id>'
export CLIENT_SECRET='<client-secret-pkcs8-der-hex>'
export TURN_PROJECT_ENDPOINT='<project-endpoint>'
export TURN_REALM='<cluster-domain>'
 
node <<'EOF' | jq
(async () => {
  const crypto = require('crypto');
  const clientId = process.env.CLIENT_ID;
  if (!clientId) throw new Error('Please define CLIENT_ID');
  const clientSecretHex = process.env.CLIENT_SECRET;
  if (!clientSecretHex) throw new Error('Please define CLIENT_SECRET');
  const ttl = Number(process.env.TURN_TTL || 86400);
  const projectEndpoint = process.env.TURN_PROJECT_ENDPOINT;
  if (!projectEndpoint) throw new Error('Please define TURN_PROJECT_ENDPOINT');
  const realm = process.env.TURN_REALM;
  if (!realm) throw new Error('Please define TURN_REALM');
  const exp = Math.floor(Date.now() / 1000) + ttl;
  const username = 'v1:' + exp + ':app:' + projectEndpoint + ':' + clientId;
  const clientPrivateKey = crypto.createPrivateKey({ key: Buffer.from(clientSecretHex, 'hex'), format: 'der', type: 'pkcs8' });
  const envhex = process.env.TURN_SERVER_PUB_HEX;
  const url = 'https://rstream.io/keyrings/turn/' + realm + '.spki.der.hex';
  const serverPublicKeyHex = envhex ? envhex.trim() : (await (await fetch(url)).text()).trim();
  const serverPublicKey = crypto.createPublicKey({ key: Buffer.from(serverPublicKeyHex.replace(/\\s+/g, ''), 'hex'), format: 'der', type: 'spki' });
  const shared = crypto.diffieHellman({ privateKey: clientPrivateKey, publicKey: serverPublicKey });
  const key = crypto.hkdfSync('sha256', shared, Buffer.from(realm, 'utf8'), Buffer.from('turn-app-v1', 'utf8'), 32);
  const credential = crypto.createHmac('sha256', key).update(username, 'utf8').digest('base64');
  process.stdout.write(JSON.stringify({ username, credential, ttl, realm, project_endpoint: projectEndpoint }));
})().catch((e) => { console.error(e.message); process.exit(1); });
EOF

Selecting a credential model

API-issued credentials fit authenticated control-plane workflows and are the default hosted-platform path when the application can call the rstream control plane before opening a session. PAT-derived credentials fit third-party backends that already store a personal access token and want a local TURN derivation path with no control-plane call. The tradeoff is that any holder of that PAT can derive TURN credentials from it.

APP-derived credentials fit backends that need stricter control over TURN issuance. The backend can mint and distribute downstream app auth tokens without delegating TURN derivation authority, because those tokens are not sufficient to generate TURN credentials. TURN derivation remains limited to possession of the client_id and client_secret pair.

The token families used by the PAT and application models are documented separately in Tokens.