DEV Community

Cover image for Swapping Participant Tokens in Real-Time with Amazon IVS
Todd Sharp for AWS

Posted on • Originally published at recursive.codes

Swapping Participant Tokens in Real-Time with Amazon IVS

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
Enter fullscreen mode Exit fullscreen mode

Import the public key:

aws ivs-realtime import-public-key \
  --public-key-material "$(cat public.pem)"
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

The header includes the algorithm, your public key ARN as the kid, and the type:

{"alg": "ES384", "kid": PUBLIC_KEY_ARN, "typ": "JWT"}
Enter fullscreen mode Exit fullscreen mode

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:

  • capabilities
  • user_id
  • attributes
  • exp
  • iat

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
Enter fullscreen mode Exit fullscreen mode

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})
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

At this point, the host is publishing and the guest can see the host's video.

Host publishing on the stage

The guest joins as subscribe-only and can see the host, but isn't publishing anything yet. Notice the "Promote → Publish" button.

Guest view, subscribe-only

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);
Enter fullscreen mode Exit fullscreen mode

The guest is now publishing. No disconnect, no rejoin.

Guest promoted to publisher

And here's what the server-side composition looks like with both participants publishing:

Server-side composition with two publishers

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.

Server-side composition featured slot attribute name configuration

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.

Feature Me button available

const newToken = await fetchToken(["PUBLISH", "SUBSCRIBE"], userId, currentJti, { featured: "true" });
await stage.exchangeToken(newToken);
Enter fullscreen mode Exit fullscreen mode

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.

Guest is now featured

The server-side composition reflects the featured participant:

Server-side composition with featured guest

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);
Enter fullscreen mode Exit fullscreen mode

Unfeature Me button

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);
Enter fullscreen mode Exit fullscreen mode

Things to Watch Out For

A few gotchas I ran into while building this:

  1. Use user_id, not userId. The SDK's mutable field list uses snake_case (user_id). If you use userId (camelCase) in your token payload, the SDK treats it as immutable and will reject any exchange that changes it.

  2. Preserve the jti. The jti is immutable. Your exchange token must have the same jti as the original. Generate it once on the initial token request and reuse it for all subsequent exchanges.

  3. Self-signed tokens only. Tokens from the CreateParticipantToken API don't work with exchangeToken(). You need to sign your own JWTs.

  4. 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's stageStreamsToPublish() 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)