Version 1.33.0 of the Amazon IVS Web Broadcast SDK introduced a new exchangeToken() API that lets you swap a participant's token without disconnecting from the stage. While this may sound like a simple API enhancement, it's actually a pretty big deal and enables some powerful features in your real-time streaming application. Prior to this, if you wanted to change a participant's capabilities or attributes, you had to leave the stage and rejoin with a new token. That meant a visible interruption for the participant and everyone else on the stage. With exchangeToken(), you can promote a viewer to a publisher, demote them back, or update their attributes on the fly. No disconnect, no rejoin, no interruption. In this post, we'll look at how to implement this API in your applications.
What We're Building
A single-page app with a Python (Flask) backend and vanilla JS frontend. The application will have two roles:
- Host: joins with publish + subscribe capabilities
- Guest: joins as subscribe-only, then can be promoted to publish via token exchange
We'll also add the ability to "feature" a participant by updating their token attributes, and demote or unfeature them, all without leaving the stage.
Self-Signed JWTs
One important thing to note: exchangeToken() requires self-signed JWT tokens. Tokens generated by the CreateParticipantToken API won't work here. You'll need to create a public key resource in your AWS account and sign your own tokens with the corresponding private key using ES384.
Generate a key pair:
openssl ecparam -name secp384r1 -genkey -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pem
Import the public key:
aws ivs-realtime import-public-key \
--public-key-material "$(cat public.pem)"
This gives you a public key ARN that you'll use as the kid in your JWT header.
Token Structure
The JWT payload for an IVS real-time stage token looks like this:
{
"exp": now + 86400 #configurable,
"iat": now,
"jti": "unique-token-id",
"resource": STAGE_ARN,
"topic": STAGE_ID,
"events_url": "wss://global.events.live-video.net",
"whip_url": "https://[your-id].global-bm.whip.live-video.net",
"capabilities": {
"allow_publish": True,
"allow_subscribe": True,
},
"user_id": "some-user",
"attributes": {},
"version": "1.0",
}
The header includes the algorithm, your public key ARN as the kid, and the type:
{"alg": "ES384", "kid": PUBLIC_KEY_ARN, "typ": "JWT"}
Mutable vs. Immutable Fields
This is the part that will trip you up if you're not paying attention. When you exchange a token, the SDK validates that certain fields haven't changed between the original and new token. The fields that can change are:
capabilitiesuser_idattributesexpiat
Everything else (jti, resource, topic, whip_url, events_url, version) must be identical. If any immutable field differs, the SDK rejects the exchange before it even hits the server.
This means you need to preserve the jti from the original token and reuse it when generating the exchange token. In our demo, the backend returns the jti with the token response, and the frontend passes it back when requesting an exchange token.
The Backend
The Flask backend is straightforward. One endpoint generates tokens, accepting capabilities, user ID, attributes, and an optional jti for exchanges:
def generate_stage_token(user_id="", attributes=None,
publish=True, subscribe=True, jti=None):
now = int(time.time())
token_jti = jti or secrets.token_hex(6)
payload = {
"exp": now + 86400,
"iat": now,
"jti": token_jti,
"resource": STAGE_ARN,
"topic": STAGE_ID,
"events_url": EVENTS_URL,
"whip_url": WHIP_URL,
"capabilities": {
"allow_publish": publish,
"allow_subscribe": subscribe,
},
"user_id": user_id,
"attributes": attributes or {},
"version": "1.0",
}
headers = {"alg": "ES384", "kid": PUBLIC_KEY_ARN, "typ": "JWT"}
return jwt.encode(payload, PRIVATE_KEY,
algorithm="ES384", headers=headers), token_jti
The /api/token endpoint accepts a JSON body and returns the signed token along with the jti:
@app.route("/api/token", methods=["POST"])
def create_token():
body = request.json or {}
caps = body.get("capabilities", ["PUBLISH", "SUBSCRIBE"])
user_id = body.get("userId", "")
attributes = body.get("attributes")
jti = body.get("jti")
token, token_jti = generate_stage_token(
user_id=user_id,
attributes=attributes,
publish="PUBLISH" in caps,
subscribe="SUBSCRIBE" in caps,
jti=jti,
)
return jsonify({"token": token, "jti": token_jti})
The Frontend
On the frontend, we use the IVS Web Broadcast SDK (1.33.0) to join a stage and handle token exchanges. Here's the fetchToken helper that talks to our backend:
let currentJti = null;
async function fetchToken(capabilities, userId, jti, attributes) {
const res = await fetch("/api/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ capabilities, userId, jti, attributes }),
});
const data = await res.json();
currentJti = data.jti;
return data.token;
}
We store the jti from the response so we can pass it back for subsequent exchanges.
Joining the Stage
The host joins with publish + subscribe. The guest joins with subscribe-only:
const caps = isGuest ? ["SUBSCRIBE"] : ["PUBLISH", "SUBSCRIBE"];
const userId = isGuest ? "guest" : "host";
const token = await fetchToken(caps, userId);
stage = new Stage(token, strategy);
await stage.join();
At this point, the host is publishing and the guest can see the host's video.
The guest joins as subscribe-only and can see the host, but isn't publishing anything yet. Notice the "Promote → Publish" button.
Promoting a Guest to Publisher
When the guest clicks "Promote → Publish", we request a new token with publish capabilities, grab the user's camera, and call exchangeToken():
const newToken = await fetchToken(["PUBLISH", "SUBSCRIBE"], "guest-promoted", currentJti);
const devices = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
localStreams = devices.getTracks().map((t) => new LocalStageStream(t));
canPublish = true;
await stage.exchangeToken(newToken);
The guest is now publishing. No disconnect, no rejoin.
And here's what the server-side composition looks like with both participants publishing:
Featuring a Participant
Once a participant is publishing, they can be "featured" by exchanging their token with an updated attributes field. Since attributes is in the mutable set, this works without any issues.
If you're using server-side composition, you'll need to configure it to know which attribute to look for when determining the featured participant. When starting a composition, set the "Featured slot attribute name" to match the attribute key you're using in your token. In our case, it is featured.
When a participant's token includes featured: "true" in their attributes, the composition will automatically give them the featured slot in the layout. This is what makes exchangeToken() so useful here. You can toggle a participant in and out of the featured slot just by swapping their token attributes, and the composition reacts accordingly.
const newToken = await fetchToken(["PUBLISH", "SUBSCRIBE"], userId, currentJti, { featured: "true" });
await stage.exchangeToken(newToken);
After the exchange, the participant's attributes are updated. You could use the STAGE_PARTICIPANT_METADATA_CHANGED event to react to this on other clients, for example to highlight the featured participant in the UI.
The server-side composition reflects the featured participant:
Unfeaturing and Demoting
Unfeaturing works the same way, just exchange with empty attributes:
const newToken = await fetchToken(["PUBLISH", "SUBSCRIBE"], userId, currentJti, {});
await stage.exchangeToken(newToken);
And demoting back to subscribe-only is just another exchange with reduced capabilities. Make sure to stop the local media tracks and pass empty attributes to clear the featured status:
const newToken = await fetchToken(["SUBSCRIBE"], "guest", currentJti, {});
localStreams.forEach((s) => s.mediaStreamTrack.stop());
localStreams = [];
canPublish = false;
await stage.exchangeToken(newToken);
Things to Watch Out For
A few gotchas I ran into while building this:
Use
user_id, notuserId. The SDK's mutable field list uses snake_case (user_id). If you useuserId(camelCase) in your token payload, the SDK treats it as immutable and will reject any exchange that changes it.Preserve the
jti. Thejtiis immutable. Your exchange token must have the samejtias the original. Generate it once on the initial token request and reuse it for all subsequent exchanges.Self-signed tokens only. Tokens from the
CreateParticipantTokenAPI don't work withexchangeToken(). You need to sign your own JWTs.Prepare media before exchanging. If you're promoting a subscriber to a publisher, get the user's camera/mic and update your strategy before calling
exchangeToken(). The SDK will call your strategy'sstageStreamsToPublish()after the exchange completes.
Summary
The exchangeToken() API in SDK 1.33.0 opens up a lot of possibilities for dynamic stage management. Promoting viewers to speakers, featuring participants, adjusting capabilities on the fly - all without the disruption of a disconnect/rejoin cycle. The key is understanding which token fields are mutable and which aren't, and making sure your backend can generate exchange tokens that preserve the immutable fields from the original.
Check out the IVS Real-Time Streaming User Guide for more details on stages and token management.
Image by Igor Ovsyannykov from Pixabay









Top comments (0)