Threat Model

Your WebRTC credentials are in your bundle

The question usually arrives from a team shipping video or voice: our TURN username and password are in the JavaScript, so if we obfuscate the bundle are they safe? It is a reasonable question and it has an unusually crisp answer, because unlike most client-side secret arguments this one has a bill attached to getting it wrong.

What actually ships

A WebRTC application configures its peer connection with a list of ICE servers. In source that is an array handed to RTCPeerConnection, and each entry names a server URL and, where the server requires authentication, a username and a credential. Whatever else your build does, that array has to reach the browser, because the browser is what opens the connection. There is no arrangement in which the client establishes an authenticated relay session without holding the material that authenticates it.

So the question is never whether the credential is present. It is present. The question is how long it stays useful once somebody has it, and that is a property of how you issue it rather than of how you compile the code around it.

STUN is not the problem. TURN is the one with a bill attached

The two kinds of ICE server in that list have very different exposure. A STUN server answers a simple question, telling a client what its address looks like from the outside so that peers can attempt a direct connection. It is typically unauthenticated, it carries no media, and a public STUN URL in your bundle is not a leak in any meaningful sense.

TURN is different in the way that matters. When a direct path cannot be established, and in real networks that is a substantial minority of sessions, the media is relayed through a TURN server. Relayed media consumes real bandwidth on a server somebody pays for, which is precisely why TURN is authenticated when STUN is not. A long-lived TURN credential extracted from a bundle is a working, billable relay account, and relay capacity has an obvious secondary market. This is the rare client-side leak with a direct and immediate cost attached, which tends to concentrate the mind faster than an abstract confidentiality argument.

What protection changes, and what it does not

Renaming and string handling do have an effect here, and it is worth stating accurately rather than dismissing. After a transform, searching a deployed bundle for turn: or for your provider's hostname will not produce a hit, because the literal is no longer sitting in the file as a plain run of characters. Someone doing an opportunistic sweep across many sites for exposed credentials will not find yours. That is a genuine reduction in casual exposure and it is not nothing.

What it does not do is change the two places the credential is unavoidably legible. The first is the live object: the configuration is assembled in memory before the connection opens, and anyone who can run code in the page or pause it in a debugger can read the assembled value regardless of how the file was written. The second is the wire, where the credential is presented to the TURN server in the form the protocol specifies, because otherwise the server would have nothing to check. Neither of those is affected by how the source was transformed, for the same structural reason that an API key cannot be hidden in a bundle.

It is also worth noting what the domain lock does not do here, because it is a natural thing to reach for. That guard has the protected code read the hostname of the page it is running on and compare it against an expected value. It is a client-side consistency check and a reasonable way to notice casual reuse of your bundle, but the TURN server never sees the result and an attacker holding the credential has no reason to run your code at all.

The fix is an expiry, and it lives on your server

The standard answer to this, implemented by the common TURN servers and supported by the managed providers, is to stop shipping a static credential entirely and issue a short-lived one instead. Your own backend exposes an endpoint that returns a fresh pair. The username is an expiry timestamp, generally with a user identifier appended, and the credential is an HMAC over that username computed using a secret shared only between your service and the TURN server.

The property that makes this work is that the TURN server does not need to store anything per user. It recomputes the HMAC from the username it was handed and the secret it already holds, and it checks the timestamp. Your shared secret never leaves your infrastructure. The client only ever holds a credential that expires, and the window in which a leaked one is worth anything shrinks from indefinitely to minutes.

This also gives you something the static approach cannot: attribution. Because your server mints every credential, you know who asked and when, so unusual relay consumption can be traced to an account instead of being an unexplained line on an invoice.

What is not a secret, despite looking like one

Session descriptions contain several values that read like credentials and are not. The ICE username fragment and password are generated fresh for each negotiation and are meaningful only inside it. The DTLS fingerprint is a digest of a certificate that exists specifically to be presented to the other party and checked. These are exchanged over signaling by design, and trying to conceal them confuses the protocol without improving anything.

The part of that path genuinely worth attention is the signaling channel itself. If an unauthenticated party can join a negotiation or read one, the problem is authorisation on your signaling service, not the visibility of the fields. That is a server-side control and it is where the effort belongs.

What is worth protecting in a media client

Setting credentials aside, a mature WebRTC client usually contains a real amount of hard-won engineering: bandwidth estimation and how you respond to it, the reconnection and fallback ladder, quality and codec selection heuristics, jitter handling, and the specific shape of your signaling protocol. None of that is secret in the cryptographic sense, and all of it is expensive to reproduce. Raising the cost of lifting it wholesale is a reasonable and achievable goal, and it is the same argument that applies to a custom wire protocol.

That is the honest split. Protect the engineering because reading cost is the thing a transform actually raises. Fix the credential on the server, because no amount of reading cost changes what the client has to present.

A short checklist

  • Search your deployed bundle for your TURN provider's hostname before you do anything else, since finding a static credential settles the priority immediately.
  • Move credential issuance to an endpoint on your own backend that returns a timestamped username and an HMAC, and keep the shared secret out of any artefact that reaches a browser.
  • Choose an expiry in minutes and have the client re-request rather than widening the window to cover the longest imaginable session.
  • Leave STUN URLs alone, and leave the ICE and DTLS values in the SDP alone.
  • Put authorisation on the signaling service, because that is the control that decides who can join a negotiation.
  • Alert on relay bandwidth per account, which is the signal that tells you a credential leaked regardless of how it happened.

The short version

A TURN credential in a protected bundle is harder to grep for and just as usable. The transform helps with the opportunistic sweep and does not touch the live object or the wire, which is where anyone deliberately looking will go. Issue credentials that expire, keep the shared secret on your server, and spend the protection budget on the engineering in your media client, which is the part where raising reading cost genuinely buys you something.

Frequently asked questions

Is it safe to put TURN credentials in an obfuscated bundle?

Not if they are long-lived, because the protection does not change what has to happen for the connection to work. Your code has to present those credentials to the TURN server, so they exist in memory in a readable form at the moment of use and they cross the network in a form the server accepts. A transform raises the cost of finding them by reading the file, which is real but partial, and it does nothing about either of the other two observation points. The property you want is a credential that stops being useful quickly, not one that is harder to locate.

What does an ephemeral TURN credential look like?

The widely deployed pattern is to have your own server mint one on request. The username is an expiry timestamp, usually with a user identifier appended, and the credential is an HMAC over that username computed with a secret shared only between your service and the TURN server. The TURN server can validate it without any per-user database because it can recompute the HMAC itself. Your client fetches a fresh pair when it needs to open a connection, and the pair stops working after the timestamp passes.

How short should the expiry be?

Short enough that a stolen credential is not worth reselling, long enough that a legitimate call survives a network hiccup. A few minutes to an hour covers most applications, with the important detail being that the lifetime applies to obtaining a new allocation rather than to a call already in progress. If your sessions routinely run longer than the window, have the client request a fresh credential rather than stretching the window to cover the worst case, since the long window is what an attacker gets too.

Does LockDomain stop someone using our TURN credentials from another site?

No, and it is worth being precise about why. The domain lock is a check the protected code performs on itself by reading the hostname of the page it is running on, which is a value supplied by the environment the code is running in. It is a deployment expectation check, useful for noticing casual reuse. It is not an authentication step and the TURN server never sees it. Anyone extracting the credential is not obliged to run your code at all, so the check is simply not on the path.

Are the ICE and DTLS values in the SDP secrets as well?

No, and treating them as secrets wastes effort that belongs elsewhere. The ICE username fragment and password are generated per session and are meaningful only within that negotiation, and the DTLS fingerprint is a digest of a certificate whose purpose is to be presented and checked by the other party. These values are designed to be exchanged over signaling. The thing worth guarding in the SDP path is your signaling channel itself, so that a third party cannot inject into or read a negotiation they were not part of.

If none of this is fixed by obfuscation, is it worth protecting a WebRTC client at all?

Yes, but for the reasons it is actually good at rather than for credential secrecy. A media application usually contains a meaningful amount of engineering that is not a secret in the cryptographic sense and is still expensive to reproduce: bandwidth estimation heuristics, reconnection and fallback logic, quality selection, and the shape of your signaling protocol. Raising the cost of lifting that wholesale is a reasonable objective. Storing a long-lived shared credential is a different problem and needs the server-side fix regardless.

Related reading