# Identity delegation for Mesh integrations

> Let your Mesh integration call Uniform APIs as the signed-in dashboard user instead of using a static service account.

Source: https://docs.uniform.app/docs/integrations/mesh-integrations/identity-delegation

## What identity delegation is

**Identity delegation** lets your Mesh integration call Uniform APIs as the signed-in dashboard user (a real person) instead of as a shared, static [service account](https://docs.uniform.app/docs/guides/api-access/service-accounts). Each call is attributed to that user and runs with that user's permissions.

Mesh integrations are custom applications with their own deployments, embedded as iframes in the Uniform dashboard. When one needs to create or update Uniform entities, it authenticates to the Uniform APIs through the [SDK clients](https://docs.uniform.app/docs/sdk#utility-sdks), but because it runs in an iframe, it doesn't inherit the current user's session or permissions.

Without identity delegation, the usual workaround is to build a custom backend-for-frontend (BFF), a small server of your own that sits between the iframe and Uniform, and inject a static [service account](https://docs.uniform.app/docs/guides/api-access/service-accounts). That works, but it has real drawbacks:

- The service account isn't tied to any person. Every call runs with the same fixed permissions and carries no per-user attribution.
- The BFF is publicly exposed, so it needs its own authentication layer. Without one, unauthorized callers could drive its service-account credentials and make content updates they should never be allowed to make.

Identity delegation closes that gap: Uniform mints a short-lived credential scoped to the person using the dashboard, your server exchanges it with your integration secret, and subsequent Uniform API calls run as that user.

### When to use it

Reach for identity delegation when your integration **creates or modifies data through Uniform SDKs on behalf of the person using it**, and that person's identity matters. For example:

- An **importer** that writes entries, compositions, or assets into a project, so the changes are attributed to the user who ran the import and respect that user's permissions.
- A **custom content editor or authoring tool** that reads and writes Uniform content from inside a Mesh location, where each editor should only be able to do what their Uniform role allows.

With delegation, those calls are made on behalf of the signed-in user, so:

- History logs show the real user as the actor.
- Authorization runs against that user's Uniform roles and permissions.
- You don't distribute or rotate long-lived service account keys for interactive flows.

> **The delegation access token carries the user's full authority:**
>
> A delegation access token has the **same API authority as the signed-in user's own session**. It is not a read-only or project-scoped subset. If the user is a team admin, the token can perform admin actions too (member and role management, integration credential rotation, team settings, and other admin-gated operations). Expose via BFF only the APIs your integration actually needs.

## Get started

The fastest path is to start from the reference integration, enable delegation on the manifest, and wire credentials into your app environment.

- A Uniform team and project
- Permission to create or edit a custom Mesh integration
- Local HTTPS (required for delegation cookies; the example uses `next dev --experimental-https`)
- Uniform npm packages **20.72.3** or higher (identity delegation is available only from this version of `@uniformdev/mesh-sdk`, `@uniformdev/mesh-sdk-react`, and related `@uniformdev/*` packages)

### 1. Start from the example

Clone the [mesh-auth](https://github.com/uniformdev/examples/tree/main/mesh/mesh-auth) reference app. It is a minimal Next.js Mesh integration that demonstrates the full delegation lifecycle through a project tool.

```bash
git clone -b mesh-identity-delegation-examples https://github.com/uniformdev/examples.git
cd examples/mesh/mesh-auth
npm install
cp .env.example .env
```

### 2. Enable identity delegation on the manifest

In your custom integration definition (Settings → Custom Integrations), set `"identityDelegation": true` on the manifest and point `baseLocationUrl` at your HTTPS origin (local or deployed).

`mesh-manifest.json (excerpt)`

```json
{
  "type": "mesh-auth",
  "displayName": "Mesh Auth Example",
  "baseLocationUrl": "https://localhost:9002",
  "identityDelegation": true,
  "locations": {
    "projectTools": [
      {
        "id": "delegation-demo",
        "name": "Delegation Demo",
        "url": "/delegation-demo"
      }
    ]
  }
}
```

![Setting identityDelegation to true in the custom integration's manifest editor.](https://docs.uniform.app/images/integrations/identity-delegation/identity-delegation-manifest.png)

Register or update the integration, then install it on a project.

### 3. Generate secrets and fill `.env`

In the dashboard, generate the integration's delegation credentials (integration ID and app secret). Open the integration's **Credentials** tab and choose **Generate secret**.

![The Credentials tab of a custom integration with the Generate secret button.](https://docs.uniform.app/images/integrations/identity-delegation/integration-tab-credentials.png)

Uniform shows the integration ID and app secret once. Copy both values before closing the dialog, because the secret can't be retrieved again.

![The credentials dialog showing the integration ID and app secret, displayed only once on generation.](https://docs.uniform.app/images/integrations/identity-delegation/modal-credentials.png)

Put them in the mesh app `.env` together with API hosts and a cookie-sealing secret:

| Variable | Purpose |
| --- | --- |
| `UNIFORM_API_HOST` | Uniform API origin. Use `https://uniform.app` for US-region teams and `https://eu.uniform.app` for EU-region teams. |
| `UNIFORM_EDGE_API_HOST` | Uniform edge API host (for example `https://uniform.global`) |
| `UNIFORM_INTEGRATION_ID` | Integration definition ID (copy from integration's credentials) |
| `UNIFORM_INTEGRATION_SECRET` | Integration app secret server only; never shipped to the browser (copy from integration's credentials) |
| `MESH_SESSION_SECRET` | Long random string (at least 32 bytes) used to derive the key that encrypts your session cookie. Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` |
| `MESH_ALLOWED_ORIGINS` | Comma-separated HTTPS origin(s) of this mesh app (for example `https://localhost:9002`) |

### 4. Run over HTTPS

```bash
npm run dev
```

The example serves on `https://localhost:9002`. Open **Delegation Demo** from the project tools menu. Your browser may show a one-time trust warning for the self-signed certificate.

> **Why HTTPS locally?:**
>
> Delegation cookies use `Secure`. Browsers will not attach them on `http://localhost`. Do not disable `Secure` for local development. Serve HTTPS instead. Details are in [Security mitigations](#security-mitigations) and [Troubleshooting](#troubleshooting).

## How Uniform implements this (RFC 8693)

Under the hood, identity delegation is an [IETF RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange.

At a high level:

1. The dashboard issues a very short-lived **session token** to your iframe (proof that a signed-in user consented to act through your integration).
2. Your **server** exchanges that token, together with your integration credentials, for a **delegation access token**: a bearer JWT scoped to that user.
3. Your BFF calls Uniform APIs with `Authorization: Bearer …` using that access token. The browser never holds the access token or your integration secret.

Because the exchange happens on your server, neither your integration secret nor access token never reach the browser.

When the access token expires (on the order of minutes), your app obtains a **new** session token from the dashboard and exchanges it again. The reference example does not use backend refresh tokens.

### Key terms

| Term | Meaning |
| --- | --- |
| Session token | Extremely short-lived token (about 10 seconds) issued by the dashboard to the iframe via the Mesh SDK. Obtained in the browser with `sdk.getSessionToken()`. Send it once to your server; never store it in the browser. |
| Delegation access token | Bearer JWT issued by Uniform when your server exchanges the session token using your integration credentials. Used as `Authorization: Bearer …` when calling Uniform APIs on behalf of the user. |

```mermaid
sequenceDiagram
    participant Dashboard
    participant Iframe as Mesh iframe
    participant BFF as Your BFF
    participant Uniform as Uniform token API

    Dashboard->>Iframe: Load location
    Iframe->>Dashboard: getSessionToken()
    Dashboard-->>Iframe: Session token (short TTL)
    Iframe->>BFF: POST /api/session
    BFF->>Uniform: Token exchange (RFC 8693)
    Uniform-->>BFF: Delegation access token
    BFF-->>Iframe: Set-Cookie (sealed, HttpOnly)
    Iframe->>BFF: GET /api/...
    BFF->>Uniform: API call with Bearer token
```

## Mesh SDK

`@uniformdev/mesh-sdk-react` covers the React iframe UI. `@uniformdev/mesh-sdk/server` covers the BFF exchange and session cookie helpers. The [mesh-auth example](https://github.com/uniformdev/examples/tree/main/mesh/mesh-auth) wires all of this end to end.

> **Wrap only what needs delegation:**
>
> `DelegationProvider` and `DelegationGate` run `checkActive`, may call `getSessionToken()`, and render loading or disabled UI. That behavior is only appropriate for surfaces that actually call user-scoped Uniform APIs through your BFF. Static settings pages, marketing shells, or locations that rely solely on a static service account should stay outside the delegation tree, so they do not block on session exchange or show "delegation disabled" when the user never needed it.

### React: provider, gate, and fetch

- `DelegationProvider`: runs `checkActive` first; if inactive, calls `sdk.getSessionToken()` and your `onSessionToken` handler to establish the sealed session cookie. Tracks status (`idle` → `acquiring` → `active` | `disabled` | `error`). Optionally revalidates when the tab becomes visible again after being hidden.
- `DelegationGate`: renders children only when status is `active`; supports loading, disabled, and error slots.
- `useDelegationFetch`: `fetch` wrapper that attaches the Mesh CSRF header and, on `401` with `code: 'delegation_expired'`, re-exchanges a session token and retries the request once.
- `useDelegation`: `{ status, error, reacquire }` for custom UI under the provider.

`Project tool with DelegationProvider`

```tsx
import {
  DelegationGate,
  DelegationProvider,
  useDelegationFetch,
  useMeshLocation,
  useUniformMeshSdk,
} from '@uniformdev/mesh-sdk-react'

import { checkActive, onSessionToken } from '../lib/delegationSessionCallbacks'

function DelegationDemoContent() {
  const { metadata } = useMeshLocation<'projectTool'>()
  const delegationFetch = useDelegationFetch()

  const load = async (compositionId: string) => {
    const params = new URLSearchParams({
      projectId: metadata.projectId,
      compositionId,
    })
    const res = await delegationFetch(`/api/composition?${params}`)
    if (!res.ok) {
      throw new Error(await res.text())
    }
    return res.json()
  }

  return <button onClick={() => load('…')}>Fetch composition</button>
}

export default function DelegationDemo() {
  const sdk = useUniformMeshSdk()

  return (
    <DelegationProvider sdk={sdk} checkActive={checkActive} onSessionToken={onSessionToken}>
      <DelegationGate
        loadingComponent={<p>Connecting…</p>}
        disabledComponent={<p>Identity delegation is not enabled for this integration.</p>}
      >
        <DelegationDemoContent />
      </DelegationGate>
    </DelegationProvider>
  )
}
```

Your `checkActive` / `onSessionToken` callbacks talk only to your BFF (for example `GET /api/status` and `POST /api/session`). The HttpOnly cookie isn't readable from JavaScript, so status must come from the server.

`Session callbacks (browser → BFF)`

```ts
import { CSRF_HEADER_NAME, CSRF_HEADER_VALUE } from '@uniformdev/mesh-sdk'

export async function checkActive(): Promise<boolean> {
  const res = await fetch('/api/status', {
    headers: { [CSRF_HEADER_NAME]: CSRF_HEADER_VALUE },
  })
  if (!res.ok) return false
  const body = (await res.json()) as { status: string }
  return body.status === 'active'
}

export async function onSessionToken(sessionToken: string): Promise<void> {
  const res = await fetch('/api/session', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      [CSRF_HEADER_NAME]: CSRF_HEADER_VALUE,
    },
    body: JSON.stringify({ sessionToken }),
  })
  if (!res.ok) {
    throw new Error((await res.text()) || `Session exchange failed (${res.status})`)
  }
}
```

### Server: `DelegationTokenClient`

Exchange the session token on your server with `DelegationTokenClient` from `@uniformdev/mesh-sdk/server`. The integration secret never leaves the server.

`Exchange session token`

```ts
import { DelegationTokenClient } from '@uniformdev/mesh-sdk/server'

const tokenClient = new DelegationTokenClient({
  apiHost: process.env.UNIFORM_API_HOST!,
  integrationId: process.env.UNIFORM_INTEGRATION_ID!,
  integrationSecret: process.env.UNIFORM_INTEGRATION_SECRET!,
})

const token = await tokenClient.exchangeSessionToken(sessionTokenFromBrowser)
// token.accessToken, token.tokenType ('Bearer'), token.expiresIn (seconds)
```

On failure the client throws `DelegationTokenError` with a stable `kind` (`bad_request`, `unauthenticated`, `forbidden`, `not_found`, `rate_limited`, `server_error`, or `unknown`). Branch on `error.kind` rather than parsing message text.

Store the access token with the server helpers (`sealDelegationSession`, `serializeSessionCookie`, `unsealDelegationSession`, `parseCookies`). Then load the cookie on each BFF route and pass `bearerToken: session.accessToken` into Uniform SDK clients (for example `CanvasClient`).

Use the delegation access token as the bearer token. Do **not** pass a static service account for user-scoped delegation flows.

`POST /api/session: exchange + sealed cookie`

```ts
import {
  DELEGATION_COOKIE_NAME,
  sealDelegationSession,
  serializeSessionCookie,
} from '@uniformdev/mesh-sdk/server'

const token = await tokenClient.exchangeSessionToken(sessionToken)

const sealed = await sealDelegationSession(
  {
    accessToken: token.accessToken,
    refreshToken: undefined,
    expiresAt: Date.now() + token.expiresIn * 1000,
  },
  process.env.MESH_SESSION_SECRET!
)

res.setHeader('Set-Cookie', serializeSessionCookie(DELEGATION_COOKIE_NAME, sealed))
```

`BFF call with delegation bearer token`

```ts
import { CanvasClient } from '@uniformdev/canvas'

const canvas = new CanvasClient({
  apiHost: process.env.UNIFORM_API_HOST!,
  edgeApiHost: process.env.UNIFORM_EDGE_API_HOST!,
  projectId,
  bearerToken: session.accessToken,
})

const composition = await canvas.getCompositionById({ compositionId, state })
```

When the access token expires, clear the cookie and return `401` with `code: 'delegation_expired'` so `useDelegationFetch` (or `reacquire()`) can run the exchange again.

## Security mitigations

Identity delegation puts a powerful bearer credential on your origin. Treat the BFF as a security boundary. The [mesh-auth example](https://github.com/uniformdev/examples/tree/main/mesh/mesh-auth) implements a recommended minimum.

### HTTPS only

Delegation cookies are `Secure`. Serve the mesh app over HTTPS in production and local development. Plain `http://localhost` will look like a broken session: the cookie is set after `POST /api/session` but never sent back.

### HttpOnly, sealed cookie

Keep the access token in an encrypted (JWE) `HttpOnly` cookie via `sealDelegationSession` / `serializeSessionCookie`. JavaScript can't read it. Prefer the default `__Host-` cookie name and short access-token TTL (re-exchange when expired rather than holding long-lived credentials in the browser).

Because the dashboard embeds your app in a cross-site iframe, the cookie must use `SameSite=None`, `Secure`, and `Partitioned` (CHIPS). `serializeSessionCookie` sets these for you. Do not weaken them for local development.

### CSRF and origin checks

`SameSite=None` removes the free CSRF protection of `Lax`. Guard every BFF route that touches the delegation session:

1. Require the constant `X-Mesh-Csrf: 1` header (`CSRF_HEADER_NAME` / `CSRF_HEADER_VALUE` from `@uniformdev/mesh-sdk`; `useDelegationFetch` adds it automatically).
2. Allow-list `Origin` / `Referer` against `MESH_ALLOWED_ORIGINS` (`verifyCsrf` from `@uniformdev/mesh-sdk/server`).

The header value isn't a secret. It works because browsers can't set custom headers on cross-origin requests without a CORS preflight, and these routes must not be CORS-open. Enabling permissive CORS on guarded routes silently removes this protection.

### Short TTL and no secret in the browser

- Session tokens last seconds and are one-shot into your BFF.
- Delegation access tokens last on the order of minutes; reacquire on expiry.
- `UNIFORM_INTEGRATION_SECRET` exists only on the server.

### Content Security Policy and related headers

Lock down framing and baseline browser hardening. The example sets headers such as:

`next.config.ts headers (excerpt)`

```ts
async headers() {
  return [
    {
      source: '/:path*',
      headers: [
        {
          key: 'Content-Security-Policy',
          value: 'frame-ancestors https://uniform.app https://*.uniform.app',
        },
        { key: 'Referrer-Policy', value: 'no-referrer' },
        { key: 'X-Content-Type-Options', value: 'nosniff' },
        { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
      ],
    },
  ]
}
```

## Appendix

### Cookie flags reference

| Flag | Value | Why |
| --- | --- | --- |
| `HttpOnly` | on | Scripts can't read `document.cookie`. |
| `Secure` | on, always | Required with `SameSite=None`. Browsers will not send `Secure` cookies over plain `http://`, including `http://localhost`. Do not disable `Secure` for local dev. |
| `SameSite` | `None` | Allows the cookie on cross-site iframe fetches to your origin. |
| `Partitioned` | on, always | Opts into [CHIPS](https://developers.google.com/privacy-sandbox/cookies/chips). The cookie is stored under a partition keyed by (your origin × top-level site), so it survives Chrome's third-party cookie phase-out and gives per-embedder isolation. |

Firefox and Safari support for `Partitioned` is still partial; they fall back to standard `SameSite=None; Secure` semantics, so you don't lose functionality, just partition isolation.

### Troubleshooting

- **Requests to your BFF return 401 even after a successful** `POST /api/session`**.** The session cookie is not coming back. This is almost always the `http://localhost` problem: `Secure` cookies aren't attached over plain HTTP. Serve your app over HTTPS.
- **The UI is stuck on "connecting" /** `checkActive` **never returns true.** The cookie is being set but not sent on the follow-up fetch. Confirm `SameSite=None; Secure; Partitioned`, that you call `fetch` with `credentials: 'same-origin'`, and that your origin is HTTPS.
- `sdk.getSessionToken()` **returns** `undefined`**.** Identity delegation is not enabled for this integration, or the current location/user flow can't mint a session token. Verify delegation is enabled on the integration definition.
- `DelegationTokenClient` **throws** `forbidden` **/** `unauthenticated`**.** Check `UNIFORM_INTEGRATION_ID` and `UNIFORM_INTEGRATION_SECRET`, that the secret matches an integration with delegation enabled, and that `UNIFORM_API_HOST` matches your team's region.
- `POST` **routes return 403.** The `X-Mesh-Csrf` header is missing, or `Origin` / `Referer` isn't in `MESH_ALLOWED_ORIGINS`. Do not "fix" this by opening CORS.

### Next steps

- Learn how to scaffold a new integration in [Custom Mesh integrations](https://docs.uniform.app/docs/integrations/mesh-integrations/custom-integrations).
- Pair delegation with a [Project Tools location](https://docs.uniform.app/docs/integrations/mesh-integrations/locations/project-tools) to build user-scoped tools inside Uniform.
- Choosing credentials for non-interactive work? See [API access](https://docs.uniform.app/docs/guides/api-access) and [service accounts](https://docs.uniform.app/docs/guides/api-access/service-accounts).
