# Authentication

The recommended way to authenticate widgets. Your backend exchanges access credentials for a short-lived JWT, which is injected into the widget tag as an HTML attribute. User identity is signed into that token request server-side, so no browser-visible user identifiers are needed on the widget tag.

## From your login to a widget token

Server authentication

The broker login decides the user. The browser never supplies an identity that the server trusts without checking it.

1. Ask your backend for a widget session

   Portal browser

   The customer is already signed in to your portal. Call your own protected token endpoint with that existing session.
2. [Resolve the registered user](https://docs.returning.ai/broker-integrations/users.md)

   Your backend

   Read the trusted session and map it to the same identifier used during Returning.AI enrollment. Do not copy a customer ID or referral parameter from the request into a signed token.
3. Exchange the Access ID and Access Key

   Your backend

   Send accessId, accessKey, and userIdentifiers to the Returning.AI token endpoint. The server credentials stay here.
4. Return the short-lived embed token

   Your backend to browser

   Read data.embedToken and data.expiresIn. The browser sets embed-token on the custom element; long-lived pages request a fresh token before expiry.

> A signed token is still browser-visible
>
> The token authenticates the widget. Do not put secrets in its user identifiers or log it. Removing the widget on logout clears the browser view; it does not prove an already issued token was revoked.

## What ends access?

The current service code treats these as separate checks. Confirm the supplied environment's behavior with your Returning.AI security contact before security approval.

- **New issuance.** Deactivating an Access Key blocks new embed tokens from that key.
- **Embed-token validation.** The validator checks that the key is still active, including when validating the identity proof for an established refresh session. Initial authentication checks the embed token's expiry; the established refresh path also has its own live refresh-token checks.
- **Downstream widget sessions.** Widget access and refresh credentials have a separate lifecycle. Key deactivation does not enumerate and delete all existing sessions. The embed token's 15-minute expiry is not a maximum remaining-access window for those credentials.
- **Logout and account changes.** The logout endpoint clears the supplied session credentials; removing a widget only clears the local view. Do not treat local cleanup, account removal or stopping your token route as proof that every downstream API now rejects an existing session.

Before approval, record separate validation, refresh, logout and downstream-access results after the agreed key or account change in the [handover decision gates](https://docs.returning.ai/broker-integrations/launch-checklist.md#decision-gates). Do not run revocation tests against real customers to fill this sheet.

Community API keys are a different credential family. Their [rotation guide](https://dev.returning.ai/getting-started) explains replacement-before-revocation; it does not establish SDK session termination.

## When to use

- Logged-in portals and trader dashboards where you know the user server-side.
- Any integration where server-verified identity is important.
- Custom widget bundles rendered directly inside the portal page.

## Step 1 - Get access credentials

In the Returning.AI admin panel, go to **Community Settings > Integrations > SDK Access** and generate an `accessId` and `accessKey` pair. The `accessKey` is shown once only, and revocation takes effect immediately for new token issuance. Store both values in your server's environment variables - they must never be exposed to the client.

> Keep credentials server-side
>
> Never include `RAI_ACCESS_ID` or `RAI_ACCESS_KEY` in browser JavaScript. They should only exist in environment variables on your backend.

## Step 2 - Create a backend endpoint

Your server calls the Access Key API with your `accessId` and `accessKey`, plus a `userIdentifiers` object containing the fields your widget expects. The response returns a short-lived embed token at `data.embedToken`. Your browser only receives that token.

These host implementations use Express, PHP sessions and Django. Connect the trusted-session boundary to your own login and use your configured identifier keys. They serve `GET /api/widget-token` on the same origin as your page, returning `{ embedToken, expiresIn? }` with `Cache-Control: no-store`; the upstream Returning.AI response stays wrapped in `data`. A browser request never chooses the identity being signed.

**Node.js**

Node.js + Express setup

Install the exact backend dependency:

```bash
npm install express@5.1.0
```

Use Node.js 24.16.0 or a later Node.js 24 release. Save the complete route below as `app.mjs`. It exports `createApp` and registers `GET /api/widget-token`; it does not open a listening socket.

Create your server entry point and install your existing host session middleware before the route. The middleware must read the server-side session cookie and set the request user in this shape:

```javascript
{
  identifiers: {
    "data-configured-key": "VALUE_FROM_TRUSTED_SESSION"
  }
}
```

Pass that middleware to `createApp({ hostSessionMiddleware })`. If it sets `request.user`, the default user adapter will use it. Your server entry point must open the socket after creating the app:

```javascript
import { createServer } from "node:http";
import { createApp } from "./app.mjs";

const app = createApp({ hostSessionMiddleware });
createServer(app).listen(Number(process.env.PORT || 3000), "127.0.0.1");
```

The browser must call the route as same-origin `GET /api/widget-token` with no identity in the query string, body or headers. The route returns only the flattened embed token and optional expiry.

Set these server environment variables:

```bash
RAI_ACCESS_ID=server-value
RAI_ACCESS_KEY=server-value
RAI_USER_IDENTIFIER_KEYS=data-configured-key
# Optional. Defaults to the production token endpoint.
RAI_TOKEN_ENDPOINT=https://api-v2.returning.ai/v2/api/widget-access-keys/token
# Optional milliseconds. Defaults to 10000 and is bounded.
RAI_TOKEN_TIMEOUT_MS=10000
```

Keep the frontend and this route on one origin. Do not add CORS or accept a browser-supplied identifier as a shortcut.

Complete token route

`app.mjs`

```javascript
import express from "express";

export const DEFAULT_TOKEN_ENDPOINT =
  "https://api-v2.returning.ai/v2/api/widget-access-keys/token";

function noStore(response) {
  response.set("Cache-Control", "no-store, max-age=0");
  response.set("Pragma", "no-cache");
}

function jsonError(response, status, error) {
  noStore(response);
  return response.status(status).json({ error });
}

function readList(value) {
  return String(value ?? "")
    .split(",")
    .map((item) => item.trim())
    .filter(Boolean);
}

function readTimeout(value) {
  const timeoutMs = Number(value ?? 10_000);
  return Number.isFinite(timeoutMs) && timeoutMs > 0 && timeoutMs <= 30_000
    ? Math.floor(timeoutMs)
    : Number.NaN;
}

export function readConfig(env = process.env) {
  return {
    accessId: String(env.RAI_ACCESS_ID ?? "").trim(),
    accessKey: String(env.RAI_ACCESS_KEY ?? "").trim(),
    userIdentifierKeys: readList(env.RAI_USER_IDENTIFIER_KEYS),
    tokenEndpoint: String(
      env.RAI_TOKEN_ENDPOINT || DEFAULT_TOKEN_ENDPOINT,
    ).trim(),
    timeoutMs: readTimeout(env.RAI_TOKEN_TIMEOUT_MS),
  };
}

function getConfigurationProblems(config) {
  const problems = [];
  if (!config.accessId) problems.push("RAI_ACCESS_ID");
  if (!config.accessKey) problems.push("RAI_ACCESS_KEY");
  if (config.userIdentifierKeys.length === 0) {
    problems.push("RAI_USER_IDENTIFIER_KEYS");
  }
  if (
    config.userIdentifierKeys.some(
      (key) => !key.startsWith("data-") || key.length <= 5 || /\s/.test(key),
    )
  ) {
    problems.push("RAI_USER_IDENTIFIER_KEYS");
  }
  if (!Number.isFinite(config.timeoutMs)) problems.push("RAI_TOKEN_TIMEOUT_MS");
  return problems;
}

function isCrossSiteRequest(request) {
  if (String(request.get("sec-fetch-site") || "").toLowerCase() === "cross-site") {
    return true;
  }

  const origin = request.get("origin");
  if (!origin) return false;

  let originUrl;
  try {
    originUrl = new URL(origin);
  } catch {
    return true;
  }

  const requestProtocol = request.protocol === "https" ? "https:" : "http:";
  const requestHost = String(request.get("host") || "").toLowerCase();
  return (
    originUrl.protocol !== requestProtocol ||
    originUrl.host.toLowerCase() !== requestHost
  );
}

/**
 * Host-session adapter boundary.
 *
 * Install the host application's existing session/auth middleware before this
 * route. It must set request.user from the server-side session. The browser
 * may send a session cookie, but it must not choose an identity in a query,
 * body, or browser-controlled header. Replace this fallback with the host's
 * trusted lookup when integrating the example.
 */
function getAuthenticatedUser(request) {
  return request.user ?? null;
}

function selectConfiguredIdentifiers(user, keys) {
  const source = user?.identifiers;
  if (!source || typeof source !== "object" || Array.isArray(source)) {
    throw new Error("Authenticated host user has no identifier map");
  }

  const identifiers = {};
  for (const key of keys) {
    const value = source[key];
    if (
      typeof value !== "string" &&
      !(typeof value === "number" && Number.isFinite(value))
    ) {
      throw new Error(`Authenticated host user is missing ${key}`);
    }
    const normalized = String(value).trim();
    if (!normalized) throw new Error(`Authenticated host user is missing ${key}`);
    identifiers[key] = normalized;
  }
  return identifiers;
}

async function requestEmbedToken(config, userIdentifiers, fetchImpl) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), config.timeoutMs);

  try {
    const response = await fetchImpl(config.tokenEndpoint, {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        accessId: config.accessId,
        accessKey: config.accessKey,
        userIdentifiers,
      }),
      signal: controller.signal,
      cache: "no-store",
    });

    if (!response?.ok) {
      throw new Error("Returning.AI token endpoint returned a non-success status");
    }

    let payload;
    try {
      payload = await response.json();
    } catch {
      throw new Error("Returning.AI token response was not valid JSON");
    }

    const data =
      payload && typeof payload === "object" && !Array.isArray(payload)
        ? payload.data
        : null;
    if (!data || typeof data !== "object" || Array.isArray(data)) {
      throw new Error("Returning.AI token response had malformed data");
    }

    const embedToken = data.embedToken;
    if (typeof embedToken !== "string" || !embedToken.trim()) {
      throw new Error("Returning.AI token response did not include data.embedToken");
    }

    if (
      data.expiresIn !== undefined &&
      !(
        typeof data.expiresIn === "number" &&
        Number.isFinite(data.expiresIn) &&
        data.expiresIn > 0
      )
    ) {
      throw new Error("Returning.AI token response had invalid data.expiresIn");
    }

    return data.expiresIn === undefined
      ? { embedToken }
      : { embedToken, expiresIn: data.expiresIn };
  } finally {
    clearTimeout(timeout);
  }
}

/**
 * Create the host application route.
 *
 * `hostSessionMiddleware` is the existing host session/auth setup. The
 * default is intentionally unauthenticated so an unwired example cannot mint
 * a token. The route itself remains independent of the frontend framework.
 */
export function createApp({
  env = process.env,
  fetchImpl = globalThis.fetch,
  hostSessionMiddleware,
  getUser = getAuthenticatedUser,
} = {}) {
  const config = readConfig(env);
  const app = express();
  app.disable("x-powered-by");
  app.use(hostSessionMiddleware ?? ((_request, _response, next) => next()));

  app.all("/api/widget-token", async (request, response) => {
    if (request.method !== "GET") {
      return jsonError(response, 405, "Method not allowed");
    }
    if (isCrossSiteRequest(request)) {
      return jsonError(response, 403, "Cross-site token requests are not allowed");
    }

    if (getConfigurationProblems(config).length > 0) {
      return jsonError(response, 503, "The token route is not configured yet");
    }

    let user;
    try {
      user = await getUser(request);
    } catch {
      return jsonError(response, 500, "Authentication setup failed");
    }
    if (!user) return jsonError(response, 401, "Sign-in is required");

    let userIdentifiers;
    try {
      userIdentifiers = selectConfiguredIdentifiers(
        user,
        config.userIdentifierKeys,
      );
    } catch {
      return jsonError(
        response,
        500,
        "The authenticated user is missing a configured identifier",
      );
    }

    try {
      const token = await requestEmbedToken(
        config,
        userIdentifiers,
        fetchImpl,
      );
      noStore(response);
      return response.status(200).json(token);
    } catch {
      return jsonError(response, 502, "Unable to mint a widget token");
    }
  });

  app.use((_error, _request, response, _next) => {
    return jsonError(response, 500, "Request failed");
  });

  return app;
}
```

### Response shape

A successful call returns the JWT under `data.embedToken` and the lifetime in seconds under `data.expiresIn`.

```javascript
// POST https://api-v2.returning.ai/v2/api/widget-access-keys/token
// Request body: { accessId, accessKey, userIdentifiers }
// Response body (200 OK):

{
  "data": {
    "embedToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "expiresIn": 900
  }
}

// Notes:
// - embedToken: short-lived JWT, pass to the widget as the embed-token attribute
// - expiresIn: token lifetime in seconds (typically 900 = 15 minutes)
// - For long-lived views, mint a fresh token before expiry and update the
//   embed-token attribute on the widget element. The SDK watches this
//   attribute (added in 1.4.2); use the current release 1.8.11. Call reload()
//   only as a fallback for older builds.
```

## Step 3 - Inject the token into the page

Your server renders the widget tag with the `embed-token` attribute set to the token from Step 2. The SDK sends that token to Returning.AI for validation, then continues startup using the signed identity claims from the token mint request.

## Step 4 - Add the widget

Add the widget tag with `embed-token` and `bundle-url` attributes. The `bundle-url` must match the configured experience selected by `widget-id`.

```html
<rai-custom-widget
  widget-id="YOUR_WIDGET_ID"
  bundle-url="https://prod-widgets.returning.ai/custom-widget/bundle/milestones/widget.js"
  embed-token="TOKEN_FROM_YOUR_SERVER"
  height="auto"
></rai-custom-widget>
```

> Use the supplied widget ID and bundle URL
>
> Do not guess the bundle from the screen name. Different widget IDs can share one bundle, and dedicated deployments may use a client-specific host.

## How the widget reaches the page

Browser SDK and bundle delivery

All custom experiences use the same element. The supplied ID and URL select the configured widget and its code.

1. Register the custom element

   Portal browser

   Load @returningai/widget-sdk once. It registers rai-custom-widget. In a server-rendered framework, load the browser SDK on the client.
2. Validate the widget session

   SDK and Returning.AI

   The custom element reads widget-id, bundle-url, and embed-token. The SDK validates the signed token for widget authentication.
3. [Load the supplied bundle](https://docs.returning.ai/concepts/widget-types.md)

   Portal browser

   The supplied widget.js contains the experience. Different widget IDs can use the same bundle. Use the full URL provided for your environment.
4. [Render the registered user's experience](https://docs.returning.ai/broker-integrations.md#end-to-end)

   Custom widget

   The widget mounts into your page and reads its configured user state. A newly accepted backend event may still be processing.

## Mobile apps and WebViews

The Widget SDK is browser-based Web Component code. Do not add the SDK directly to a native iOS, Android, or Flutter screen. For mobile apps, host the widget on a normal web page, then load that page inside a WebView.

If your portal already has a page where the widget works in a browser, use that same page as the WebView URL. Keep the access key and token minting on your backend. The Flutter app should only open the page that receives the short-lived `embed-token` from your server.

`returning_widget_page.dart`

```dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';

class ReturningWidgetPage extends StatefulWidget {
  const ReturningWidgetPage({super.key});

  @override
  State<ReturningWidgetPage> createState() => _ReturningWidgetPageState();
}

class _ReturningWidgetPageState extends State<ReturningWidgetPage> {
  late final WebViewController controller;

  @override
  void initState() {
    super.initState();

    controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..loadRequest(
        Uri.parse('https://portal.example.com/loyalty/widget'),
      );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: WebViewWidget(controller: controller),
      ),
    );
  }
}
```

> Allow the exact WebView origin
>
> Domain checks use the origin loaded inside the WebView. `http://localhost:3000`, `http://127.0.0.1:3000`, `http://10.0.2.2:3000`, and `https://portal.example.com` are different origins. Desktop localhost may work in development mode, but Android emulator testing usually reaches the host machine through `10.0.2.2`, which may need to be allowed separately.

### User identifier keys

The browser should only receive `embed-token`. The user identifier keys themselves are sent server-side in the `userIdentifiers` object when you mint the token:

| Key in userIdentifiers | Typical source | When to use it |
| --- | --- | --- |
| `data-customer-id` | Broker CRM / platform customer ID | Preferred for most production integrations. Stable, broker-owned, and avoids exposing email. |
| `data-user-id` | Your internal user ID | Good fallback if that field already exists in your platform and matches the ID used during registration. |
| `data-email` | User email | Use only when email is already your canonical identifier or you do not have a better stable ID. |
| `data-user-objectid` | Returning.AI object ID | Only use if you already persist the Returning.AI-side user object ID and explicitly want to key off it. |

Which identifiers are **required** depends on your community's widget configuration. Check your admin panel, or ask your Returning.AI contact, for the exact required key before rolling out. Missing a required identifier in the server-side `userIdentifiers` object causes the widget to fail on auth with a `rai-error` event.

> Keep identifiers in sync with registration
>
> The configured mapping and identity value you sign into `userIdentifiers` must agree with enrollment, whether it used the API or a workflow. Request property names can differ: an enrollment `externalId`may map to the configured widget `data-customer-id` key. Confirm that mapping instead of copying a wire property name.

## Token lifecycle

Access Key tokens are short-lived JWTs that expire after 15 minutes. The SDK does not call your backend to mint a fresh `embed-token` automatically. For long-lived views, your app must fetch a new embed token, then update the `embed-token` attribute. The SDK watches this attribute (added in 1.4.2); use the current release 1.8.11. Call `reload()` only as an older-build fallback.

| Scenario | Strategy |
| --- | --- |
| Server-rendered pages | Token generated per page load. 15 minutes is plenty for most page sessions. |
| SPAs | Re-fetch the token from your backend on route navigation. Each view gets a fresh token. |
| Long-lived sessions (>15 min on one view) | Listen for the `rai-session-expired` event, re-fetch a token from your backend, update the `embed-token` attribute. The SDK watches this attribute (added in 1.4.2); use the current release 1.8.11. Call `window.ReturningAIWidget.reload()` only as an older-build fallback. |

### Handling token expiry in long-lived sessions

If a user stays on the same page for more than 15 minutes, the token will expire. Use this pattern to recover automatically:

`token-refresh.js`

```javascript
const widget = document.querySelector('rai-custom-widget')

async function refreshWidgetToken() {
  const response = await fetch('/api/widget-token')
  if (!response.ok) throw new Error('Unable to refresh widget token')

  const { embedToken } = await response.json()
  widget.setAttribute('embed-token', embedToken)
}

widget.addEventListener('rai-session-expired', refreshWidgetToken)
```
