> For the complete documentation index, see [llms.txt](https://docs.inova.us/v1/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.inova.us/v1/twilio-in-browser-call-plugin/migrating-to-v1.25.0.md).

# Migrating to v1.25.0

Version 1.25.0 is a security release. It tightens what the plugin accepts and what it sends to third parties. Most apps upgrade without changes, but **four changes can break a working app**, and **two security issues can only be fixed by you** — they live in your Twilio Functions, not in the plugin.

{% hint style="danger" %}
**Please read** [**Step 1**](#step-1-secure-your-access-token-function) **even if you change nothing else.**

The Access Token Function published in earlier versions of our [Getting Started](/v1/twilio-in-browser-call-plugin/getting-started.md) guide mints a token for **whatever identity the caller asks for**, from a **public** endpoint with **CORS open to every origin**. If you followed that guide, anyone who opens your app can request a token as one of your agents — receiving that agent's inbound calls and placing outbound calls billed to your Twilio account. No password is needed.

This applies to **older plugin versions too**. Upgrading to 1.25.0 does not fix it on its own.
{% endhint %}

Budget about 30 minutes: 10 for the plugin changes, 20 for your Twilio Functions.

## At a glance

<table><thead><tr><th width="330">Change</th><th>Do you need to act?</th></tr></thead><tbody><tr><td>Access Token Function trusts <code>event.identity</code></td><td><strong>Yes — highest priority</strong></td></tr><tr><td>Voice Function trusts <code>appCallerId</code> and <code>To</code></td><td><strong>Yes</strong></td></tr><tr><td>Agent Identifier left blank</td><td><strong>Yes</strong> — the default is a single shared identity</td></tr><tr><td>Identity is now validated</td><td><strong>Yes, if</strong> yours contains anything outside <code>A-Z a-z 0-9 _ . + @ -</code></td></tr><tr><td><code>agent</code> now reaches your Voice Function</td><td><strong>Yes, if</strong> your function reads <code>event.agent</code></td></tr><tr><td><code>instance.data.token</code> removed</td><td><strong>Yes, if</strong> you have custom JavaScript reading it</td></tr><tr><td>Token lifetime capped at 24 hours</td><td>Only if you set a longer TTL</td></tr><tr><td>Telemetry no longer sends your email or Account SID</td><td>No — but update your own privacy policy if it mentioned this</td></tr></tbody></table>

## Before you upgrade

1. Note your current plugin version so you can roll back.
2. Copy your Access Token Function and Voice Function code somewhere safe.
3. Upgrade on a **development** version of your app first.
4. Have the [Twilio Console](https://console.twilio.com) open.

## Step 1: Secure your Access Token Function

The plugin requests a token like this, and `accessTokenURL` is a client-safe key — **it is visible in your app's page source**, and the `identity` parameter is trivially editable:

```
GET https://xxxx-1234.twil.io/access-token?identity=support_team
```

The function we previously documented used `const identity = event.identity`, so it returned a valid Voice token for any identity requested. Replace it with a version that decides the identity itself.

```javascript
exports.handler = function (context, event, callback) {
    const AccessToken = require('twilio').jwt.AccessToken;
    const VoiceGrant = AccessToken.VoiceGrant;

    const response = new Twilio.Response();
    response.setHeaders({
        // Lock this to your app's domain. "*" lets any website call this endpoint.
        'Access-Control-Allow-Origin': 'https://yourapp.com',
        'Access-Control-Allow-Methods': 'GET',
        'Content-Type': 'application/json'
    });

    // 1. Authenticate the caller. Verify a session token, signed request, or
    //    shared secret that your Bubble app sends — do not skip this step.
    const user = authenticateRequest(context, event);   // your implementation
    if (!user) {
        response.setStatusCode(401);
        response.setBody({ error: 'Unauthorized' });
        return callback(null, response);
    }

    // 2. Derive the identity from the authenticated user, NEVER from event.identity.
    const identity = 'agent_' + user.id;

    const voiceGrant = new VoiceGrant({
        outgoingApplicationSid: context.TWIML_APP_SID,
        incomingAllow: true    // set false for agents that only dial out
    });

    const token = new AccessToken(
        context.ACCOUNT_SID,
        context.TWILIO_API_KEY,      // must be an API Key SID (SK...)
        context.TWILIO_API_SECRET,
        { identity: identity, ttl: 3600 }   // keep the lifetime short
    );
    token.addGrant(voiceGrant);

    // Do not console.log the token — it would be written to your Twilio logs.
    response.setStatusCode(200);
    response.setBody({ identity: identity, token: token.toJwt() });
    return callback(null, response);
};
```

What changed and why:

<table><thead><tr><th width="260">Change</th><th>Reason</th></tr></thead><tbody><tr><td>Authenticate before minting</td><td>The endpoint is public and its URL is in your page source.</td></tr><tr><td><code>identity</code> derived server-side</td><td>Stops a visitor requesting a token as another agent.</td></tr><tr><td>CORS restricted to your domain</td><td><code>*</code> let any website call the endpoint from a victim's browser.</td></tr><tr><td>Removed <code>console.log(jwt)</code></td><td>Wrote live access tokens into your Twilio function logs.</td></tr><tr><td>Short <code>ttl</code></td><td>A leaked token stays usable until it expires.</td></tr><tr><td><code>incomingAllow: false</code> where possible</td><td>An outbound-only token cannot intercept another agent's calls.</td></tr></tbody></table>

{% hint style="info" %}
**Using a Bubble backend workflow instead of a Twilio Function?** Uncheck *"This workflow can be run without authentication"*, delete the `identity` URL parameter, and set the **Voice Access Token** action's Identity to `Current User's unique id`.
{% endhint %}

**How to check whether you are exposed:** open your app, view source, find your `accessTokenURL`, and request it in a private window with an identity that is not yours. If a token comes back, you are exposed.

## Step 2: Authorize caller ID and destination in your Voice Function

`Start Call` sends `To`, `appCallerId` and `agent` as TwiML parameters. **All three come from the browser.** Our earlier guidance — replace `context.CALLER_ID` with `event.appCallerId` — hands the choice of presented caller ID to the client.

```javascript
exports.handler = function (context, event, callback) {
    const twiml = new Twilio.twiml.VoiceResponse();

    if (!event.To) {
        twiml.say('Sorry, unable to make the call!');
        return callback(null, twiml);
    }

    // Decide the caller ID on the server. Look up which numbers this agent may
    // present and fall back to your default if the requested one is not allowed.
    const allowed = callerIdsFor(event.identity);        // your implementation
    const callerId = allowed.includes(event.appCallerId) ? event.appCallerId : context.CALLER_ID;

    // Check the destination too — without this, a malicious client can dial
    // premium-rate and international numbers on your account.
    if (!isAllowedDestination(event.To)) {               // your implementation
        twiml.say('This destination is not permitted.');
        return callback(null, twiml);
    }

    const attr = isAValidPhoneNumber(event.To) ? 'number' : 'client';
    const dial = twiml.dial({ answerOnBridge: true, callerId: callerId });
    dial[attr]({}, event.To);

    return callback(null, twiml);
};

function isAValidPhoneNumber(number) {
    return /^[\d\+\-\(\) ]+$/.test(number);
}
```

An allowlist of countries, or a block on premium-rate prefixes, is usually enough for `isAllowedDestination`.

{% hint style="warning" %}
`event.agent` is **self-reported by the browser**. Never use it for authorization or billing. The identity embedded in the access token is the value you can trust.
{% endhint %}

## Step 3: Set an explicit Agent Identifier

If you leave **Agent Identifier** blank, the plugin uses the literal string `the_user_id` — **not** the current user's id. Every user of your app then shares one identity, which means they can all receive each other's inbound calls.

Set it explicitly, to the same value your Twilio routing uses:

<table><thead><tr><th width="300">Value</th><th>Valid?</th></tr></thead><tbody><tr><td><code>Current User's unique id</code></td><td>✅ Recommended</td></tr><tr><td><code>Current User's email</code></td><td>✅ Yes</td></tr><tr><td><code>support_team</code>, <code>agent_42</code></td><td>✅ Yes</td></tr><tr><td><code>Current User's Name</code> (e.g. <code>Jane Doe</code>)</td><td>❌ Contains a space</td></tr><tr><td>Anything with <code>:</code> <code>/</code> <code>#</code> <code>&#x26;</code> or accents</td><td>❌ Rejected</td></tr></tbody></table>

If you change an identity, update your [Twilio Studio routing](/v1/twilio-in-browser-call-plugin/receiving-calls.md) to match — the identity is the address Twilio routes inbound calls to, so inbound calls stop arriving otherwise.

## Step 4: Breaking changes in v1.25.0

### Identity is now validated

**Symptom:** the **Voice Access Token** action fails with `"identity" may only contain letters, digits, and the characters _ . + @ -` or `"identity" must be 121 characters or fewer.`

The identity is signed into the token and decides which calls it can place and receive, so whitespace, newlines, quotes and path separators are now rejected. Leading and trailing spaces are trimmed automatically. See the table in Step 3 for what passes.

### `agent` now reaches your Voice Function

**Symptom:** your Voice Function behaves differently on outbound calls.

`Start Call` always sent `agent` as an undefined value — the plugin read it from the wrong place. That is fixed, so `event.agent` now carries the element's **Agent Name** for the first time. If your function has `if (event.agent)` logic, re-check it: a branch that never ran before will start running.

### `instance.data.token` has been removed

**Symptom:** custom JavaScript that read the access token off the element gets `undefined`.

The plugin stored the live token on the element where any script on the page could read it, and never read it back — the Twilio SDK keeps its own copy. If you need a token in your own workflow, call the **Voice Access Token** server-side action and use its returned `token`. `instance.data.tokenURL` is unchanged.

### Token lifetime is capped

**Time to live** is now clamped to **86400 seconds** (24 hours, Twilio's own ceiling), and blank, zero or non-numeric values fall back to **3600**. Prefer a short lifetime — the **Token Expiring** event and the **Update token** action handle renewal for you.

### Misconfigured credentials fail immediately

**Symptom:** `"API Key" does not look right...` instead of Twilio error 31202.

The action now checks Twilio's SID prefixes — Account Sid starts `AC`, API Key starts `SK`, Application Sid starts `AP`. **API Key must be the API Key SID (`SK…`)** from [API Keys](https://console.twilio.com/us1/account/keys-credentials/api-keys) — not your Account SID, and not your Account Auth Token.

## Step 5: Two smaller hardening steps

* **Sub Account Token** — element properties live in page state, so **any token bound to this field is readable by your end users**. Use short lifetimes and the narrowest grants. See [Subaccounts](/v1/twilio-in-browser-call-plugin/subaccounts.md).
* **Scoped API Key** — the bundled [APIs](/v1/twilio-in-browser-call-plugin/apis.md) authenticate with your Account SID and Auth Token, which carry full account authority. Move to a scoped Twilio API Key where you can.

## Changes that need no action

* **Telemetry no longer includes your email or Twilio Account SID.** Both were previously read in browser code and sent to our analytics on every page load. They have been removed; we now receive only the plugin name and version and the Bubble app name and version. **If your own privacy policy mentioned the old behaviour, update it.** The **App Owner Email** plugin field is no longer sent anywhere.
* **DTMF digits are no longer logged to the console.** Callers key card numbers and PINs into IVRs, and browser consoles are captured by monitoring tools.
* **The Agent Identifier is URL-encoded** before it is added to the token request, so special characters can no longer alter that request.
* **Scripts load over HTTPS** rather than protocol-relative URLs.
* **Reset Twilio Device** had an incorrect internal signature; corrected, with no behaviour change.

## After upgrading: verification checklist

On a development version of your app:

* [ ] Device status reaches **registered**
* [ ] An **outbound call** connects and presents the caller ID you expect
* [ ] An **inbound call** arrives; **Accept** and **Reject** both work
* [ ] **Mute** toggles
* [ ] **Send Digits** works against an IVR
* [ ] Microphone and speaker selection still apply
* [ ] **Token refresh** works — leave a session open past the TTL, or trigger **Update token**
* [ ] The **Twilio Error** event is not firing with an identity or credential message
* [ ] Your token endpoint **rejects a request for an identity that is not the logged-in user's**

Only then promote to live.

## Rolling back

Revert to your previous plugin version in the Bubble plugin editor.

{% hint style="warning" %}
The issues in Steps 1 and 2 exist in **older plugin versions too**. Rolling back the plugin does not undo the fixes you make in your Twilio Functions, and you should keep those regardless.
{% endhint %}

## Getting help

[Plugin support](https://inova.us/plugins-support) — please include your plugin version, the exact error text from the **Twilio Error** event, and whether your token endpoint is a Twilio Function or a Bubble backend workflow.
