> ## Documentation Index
> Fetch the complete documentation index at: https://docs.textql.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedding Data Apps

> Embed a Data App in your own product, with per-user row-level security (RLS)

A [Data App](/core/how-it-works/data-apps) isn't limited to sharing inside TextQL — you can embed it directly in your own product, so external users interact with it without ever logging into TextQL. By default, an embedded app authenticates with one shared API key, so every external viewer sees the same data.

For apps where each embedded user should see only their own rows, TextQL supports per-user Row-Level Security (RLS) on the embed: a single app, scoped per session with a short-lived API key tied to that viewer's identity. One app serves every external user — there are no per-user copies to create, no user data baked into the bundle, and no per-user URL to keep secret.

## Prerequisites

Set these up before wiring in per-user RLS:

* **An API key that can create other API keys.** Minting a session key per user (Step 2 below) is itself a call to the TextQL API, so you need one master key with **API Access Key** `write` or `write_private` [permission](/core/admin/rbac). See [Creating an API Key](/core/admin/embed#creating-an-api-key) for how to request one. Keep this key server-side only — it never reaches the browser.
* **A dedicated embed role.** A key can only mint session keys that assume roles it already holds, so create a role scoped to what an embedded viewer should be able to do — typically read-only, with raw SQL disabled.
* **A data entitlement mapping.** Row-level scoping only works if there's already a governed way to resolve a user's identity to the rows they're allowed to see (an `access.principal` / `access.secured_orders`-style entrypoint keyed on email, account, or region, for example). This mapping doesn't have to live in your ontology, and it doesn't have to sit in the same warehouse as the data it's scoping — TextQL can join across connectors, so an entitlement table in one warehouse can gate access to data in another. Build the mapping first — the app's data sources reference it, they don't create it.
* **A backend of your own.** Minting keys and seeding the session chat (Steps 2–3 below) both happen server-side, so you need a backend that can call the TextQL API and hand the resulting embed URL to your front end.

## How It Works

Your backend mints a short-lived API key per user session and sets that user's identity in the key's `clientId`. Because the key is created server-side, the browser can never choose or forge the identity. The app's data sources are governed queries that resolve the viewer's identity from the session key and filter at query time — nothing user-specific is baked into the app, and an access change (an expired key, a revoked entitlement) takes effect on the very next query.

## Step 1: Scope the App's Data Sources to the Session Identity

Write the app's data sources as governed queries that resolve the viewer from the key's `clientId` and fail closed when identity is missing:

```
let
  attr    = _tql.client_attributes_json.username
  email   = access.principal attr         -- errors if blank: no identity, no rows
  secured = access.secured_orders email   -- mandatory entitlements join
in ...
```

There's deliberately no fallback identity here: if the session key has no `clientId` (or `client_attributes_json.username` is otherwise blank), `access.principal` errors and the query returns zero rows rather than reaching for some other identity — a malformed or missing embed session must never see another viewer's (or the app owner's) data. Put the entitlements join on every path so no query can reach the underlying table unfiltered.

## Step 2: Mint a Session Key per User (Backend Only)

```python theme={null}
key = rpc("textql.rpc.public.rbac.RBACService/CreateApiKey", {
    "name": f"embed-{user_email}-{int(time.time())}",
    "assumedRoles": [EMBED_ROLE_ID],
    "expirySeconds": 3600,
    "clientId": json.dumps({"username": user_email}),
}, MASTER_KEY)
# key["apiKeySecret"] -> used for the API calls in step 3
# key["apiKeyHash"]   -> the embed authKey in step 4
```

A key can only assume roles the minting key already holds. Keep raw SQL disabled for embed sessions so only the governed entrypoints above can run.

## Step 3: Seed a Session Chat That Loads the App

The embed surface is a chat carrying the app as an artifact. Create a fresh one per session with a single instruction that loads the app and does nothing else:

```python theme={null}
chat = post("/v2/chats", {
    "question": (
        f"Refresh the data app {APP_ID} using your data app tool with "
        "action=refresh. Do not edit code, files, data sources or config. "
        "Then stop; no analysis, no tables."
    ),
    "tools": {
        "connector_ids": [CONNECTOR_ID],
        "ontology_enabled": True,
        "sql_enabled": False,
    },
}, bearer=key["apiKeySecret"])
chat_id = chat["chat_id"]
```

This takes roughly 30 seconds and produces a clean thread whose only content is the app itself. The refresh action never bakes governed data into the app — it only re-runs the app's declared sources under the session's identity.

## Step 4: Embed

```html theme={null}
<iframe
  src="https://app.textql.com/embed?authKey={{ key_hash_urlencoded }}&chatId={{ chat_id }}"
  style="width: 100%; height: 900px; border: 0;"></iframe>
```

The app opens automatically, scoped to that session's user. The same page also gives that user a governed chat next to the app, subject to the same row-level security — treat that as a feature, or hide it by sizing the iframe to the app panel alone.

## What Each User Sees

| Session `clientId`                   | App shows                                             |
| ------------------------------------ | ----------------------------------------------------- |
| `{"username": "user-a@example.com"}` | Only User A's entitled rows                           |
| `{"username": "user-b@example.com"}` | Only User B's entitled rows                           |
| Key with no `clientId`               | Zero rows (fail closed), never an unfiltered fallback |

## Security Model

* Identity is set once, at key-mint time, on your server, and travels in a signed token the browser cannot read or alter.
* Entitlements are evaluated at view time, so revoking a row changes what a user sees on their next load, immediately.
* Keys expire on the TTL you choose and can be revoked individually.
* The app bundle itself contains no row data, so a leaked bundle URL exposes layout, not rows.

## Current Limitations

* The embed opens inside a chat surface rather than a bare app-only page. A dedicated app-only embed route is on the roadmap; the flow above is the supported path today.
* Cross-app links don't work inside an embed. Because the app runs in that chat surface rather than on its own page, there's no address bar for it to drive, so `ana.navigate.available` reports `false` and an embedded app should hide or disable any link to another Data App.
* Seeding the session chat adds one assistant call (about 30 seconds) when a session starts. Mint and seed the key at login rather than at page render if that latency matters for your product.

## Getting Support

If you have questions about embedding Data Apps or run into issues, reach out to [support@textql.com](mailto:support@textql.com) or visit the [customer support page](/core/get-started/customer-support).
