## Token exchange (on-behalf-of) Token exchange ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)) lets a trusted service act **on behalf of (OBO)** a signed-in user. Frontegg calls this flow **delegation**. The service that acts is the **actor**. It sends two tokens to Frontegg: the user's access token (the **subject token**) and its own token (the **actor token**). Frontegg returns a **delegated token**: an access token for the user that also names the actor in an `act` claim. Use token exchange when: - An AI agent or a background worker must call your APIs as the user who started the task. - A backend service must call another of your services with the user's identity and permissions, not with a service identity. - Your audit trail must show both the user and the actor that acted for them. Prerequisites - A Frontegg environment with OAuth enabled. If the token endpoint returns `403 OAuth is disabled`, contact Frontegg support. - Your environment **Client ID** and **API key**, from **[ENVIRONMENT] → Configurations → Keys & domains** in the Frontegg portal. - Your Frontegg domain (for example, `https://app-xxxx.frontegg.com`) or your custom domain. - Account tokens selected in **Supported M2M authentication tokens**, under **[ENVIRONMENT] → Configurations → Authentication → M2M authentication**. See [M2M management](/ciam/guides/authentication/m2m/management). - Roles and permissions turned on: **[ENVIRONMENT] → Configurations → Entitlements → Roles**. See [Roles](/ciam/guides/authorization/rbac/roles). - For the samples: `curl`, `jq` and Node.js 20 or later. br ### How it works The flow has three participants: - **User and your app** — the user signs in. Your app has the user's access token. - **Actor** — the service or AI agent that acts for the user. It has its own Frontegg token. - **Frontegg** — validates both tokens and issues the delegated token. Before the first exchange, do the one-time setup: [enable delegation](#setup-1-enable-delegation) and [give the actor the delegation permission](#setup-2-give-the-actor-the-delegation-permission). Then each task runs steps 1 to 4: ```text User + your app Actor (agent or service) Frontegg | | | | User signs in | | |---------------------------------------------------------------->| |<----------------------------------------------------------------| | the user's access token | | | | | | Start a task, pass the | | | subject token (step 2) | | |----------------------------->| | | | step 1: get actor token | | |--------------------------------->| | |<---------------------------------| | | | | | step 3: POST /oauth/token | | | grant_type=token-exchange | | | subject_token + actor_token | | |--------------------------------->| | | Frontegg checks both tokens | | |<---------------------------------| | | delegated token (user + act) | | | | | | step 4: call your API with | | | the delegated token | | |------> Your API | ``` #### Recommended architecture You authenticate the exchange with your environment Client ID and API key. The API key also gives full access to the Frontegg Management API, so do not give it to the agent. Put the exchange in one backend service (a **token broker**): 1. Your app sends the task and the subject token to the token broker over an authenticated, encrypted internal channel. 2. The token broker holds the environment API key and the actor credentials. It runs steps 1 and 3, checks the result, and returns only the delegated token to the agent. 3. The agent calls your API with the delegated token. It never sees the API key, the actor secret or the subject token. #### Environment values used in this guide The samples start from these environment variables. Later steps set the others (`ENV_TOKEN`, `ACCOUNT_ID`, `ACTOR_TOKEN`, `SUBJECT_TOKEN`, `DELEGATED_TOKEN`). | Variable | Value | | --- | --- | | `FRONTEGG_DOMAIN` | Your Frontegg or custom domain, with no trailing slash | | `FRONTEGG_API_HOST` | The Management API host for your region | | `FRONTEGG_CLIENT_ID` | **Keys & domains** → Client ID | | `FRONTEGG_API_KEY` | **Keys & domains** → API key | | `ACTOR_CLIENT_ID`, `ACTOR_SECRET` | The actor token's client ID and secret. Setup 2 can set them for you. | | `ALLOWED_ACTOR_IDS` | For your API only: a comma-separated list of the actor IDs that you created. See [Accept only your own actors](#accept-only-your-own-actors). | ```shell export FRONTEGG_DOMAIN="https://app-xxxx.frontegg.com" export FRONTEGG_API_HOST="https://api.frontegg.com" export FRONTEGG_CLIENT_ID="your-environment-client-id" export FRONTEGG_API_KEY="your-environment-api-key" ``` When a shell sample that saves a value fails, it prints the response (or the curl or jq error) and leaves the variable empty. Look up the message in the `errors` array in [Troubleshooting](#troubleshooting). `FRONTEGG_API_HOST` depends on your region: EU, US, CA or AU. See [API gateway by region](/ciam/api/overview#api-gateway-by-region). `FRONTEGG_DOMAIN` is the domain where your users sign in. Use the same environment for every value: a token from your development environment fails in production. ### Setup 1: Enable delegation Delegation is off by default. Turn it on once per environment (for example, once for development and once for production) with the [Delegation API](/ciam/api/identity/delegation/delegationconfigurationcontrollerv1_createorupdatedelegationconfiguration). There is no portal setting for it. First, [get an environment token](/ciam/api/vendor-service/other/authenticate_vendor). The token is in the `token` field of the response: ```shell ENV_TOKEN=$(curl -sS -X POST "$FRONTEGG_API_HOST/auth/vendor" \ -H 'Content-Type: application/json' \ -d "{\"clientId\": \"$FRONTEGG_CLIENT_ID\", \"secret\": \"$FRONTEGG_API_KEY\"}" \ | jq -er '.token // error(tostring)') && export ENV_TOKEN ``` Then enable delegation: ```shell curl -X POST "$FRONTEGG_API_HOST/identity/resources/configurations/v1/delegation" \ -H "Authorization: Bearer $ENV_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "enabled": true }' ``` To check the value, send `GET` to the same URL. The response is `{ "enabled": true }`. If delegation was never configured, `GET` returns `404 Delegation configuration does not exist`. To stop all new exchanges in the environment at once (for example, during an incident), send the same `POST` with `{ "enabled": false }`. ### Setup 2: Give the actor the delegation permission The actor must hold the Frontegg permission with the key `fe.secure.write.delegation` (name: **Delegation**, description: "Acting on behalf of another user"). No role has it by default. 1. Create a role for actors, for example `Delegation actor`, in **[ENVIRONMENT] → Configurations → Entitlements → Roles**. Assign it to all accounts. Give it a **Level** number smaller than the Level of every role that account admins hold (a smaller number is stronger, and 0 is the strongest). If an admin role is already at level 0, also turn on **Strict role assignment** (**Roles → Settings**) and keep `fe.governance.assignEqualLevelRoles` out of human roles. Superusers and the environment token are not limited by levels. See [Roles](/ciam/guides/authorization/rbac/roles). 2. Add the permission `fe.secure.write.delegation` to that role. Copy the role ID from the role details, or get it from the [Get roles API](/ciam/api/identity/roles/permissionscontrollerv1_getallroles) with the environment token. 3. In **[ENVIRONMENT] → Configurations → Entitlements → Permissions**, set the **Classification type** of `fe.secure.write.delegation` to **Never**, so that accounts cannot add it to their own custom roles. 4. Create the actor in the **same account (tenant)** as the users it acts for, and assign the role to it. Frontegg accepts these actors: | Actor | JWT `type` claim | What stops it | | --- | --- | --- | | Account client credentials token | `tenantApiToken` | Deleting the token stops new actor JWTs. Actor JWTs that were already issued work until they expire. | | Account access token | `tenantAccessToken` | Deleting the token stops its exchanges at once. | | User (personal) client credentials token | `userApiToken` | Same as account client credentials token. A role change takes effect when the current actor JWT expires. | | User (personal) access token | `userAccessToken` | Same as account access token. | | Another user's access token (from login) | `userToken` | A role change or a logout takes effect when that access token expires. | For an AI agent or a service, use an **account client credentials token** or an **account access token**. Each one gives the actor its own ID in the delegated token. To create an account client credentials token with the actor role by API, call [Create client credentials token](/ciam/api/identity/api-tokens/tenantapitokensv2controller_createtenantapitoken) with the environment token. `ACCOUNT_ID` is the account's ID, from **[ENVIRONMENT] → Management → Accounts** (it is also the `tenantId` claim of the account's users' tokens). The call saves the new actor's `clientId` and `secret` in `ACTOR_CLIENT_ID` and `ACTOR_SECRET`: ```shell export ACCOUNT_ID="the-account-id" export ACTOR_ROLE_ID="the-delegation-actor-role-id" ACTOR_CREDS=$(curl -sS -X POST "$FRONTEGG_API_HOST/identity/resources/tenants/api-tokens/v2" \ -H "Authorization: Bearer $ENV_TOKEN" \ -H "frontegg-tenant-id: $ACCOUNT_ID" \ -H 'Content-Type: application/json' \ -d "{\"description\": \"OBO agent\", \"roleIds\": [\"$ACTOR_ROLE_ID\"]}" \ | jq -er 'if .clientId then . else error(tostring) end') \ && export ACTOR_CLIENT_ID=$(echo "$ACTOR_CREDS" | jq -r .clientId) ACTOR_SECRET=$(echo "$ACTOR_CREDS" | jq -r .secret) ``` The environment token is not limited by role levels, so it can assign the actor role. One actor per account An actor can act only for users in its own account. If one agent serves many customer accounts, create one actor token per account (for example, with the call above when you create the account) and select the token that matches the user's account for each task. The environment token cannot be an actor. Account access tokens have a per-account limit: see [M2M tokens](/ciam/guides/authentication/m2m/overview). br Permissions must be in the actor JWT For an account client credentials actor, Frontegg reads the permission from the `permissions` claim of the actor JWT. Make sure your environment adds permissions to the JWT and that no JWT template removes them. If the claim is missing, or holds only the placeholder `FETCH-PERMISSIONS-BY-API`, the exchange fails with a server error. br Keep the actor role for actors only. Any actor with this permission can get a token for **any user** in its account, and a delegated token for a user who holds it can itself be used as an actor token. The exchange checks the exact key `fe.secure.write.delegation`. Wildcards such as `fe.secure.*` or `fe.secure.write.*` do not pass the exchange, but users who hold them can add the permission to roles and tokens. Keep the permission and these wildcards out of every human role, including admin roles. #### Accept only your own actors Role settings reduce who can create an actor, but do not rely on them alone. Users who can create account tokens (`fe.secure.write.tenantApiTokens`) can create tokens outside your control. Make the list of actors a hard check: 1. Record the ID of every actor token that you create (the `clientId` of a client credentials token, or the ID of an access token). 2. In your API, refuse any token whose `act.sub` is not on that list. The [validation sample](#validate-the-delegated-token-in-your-api) does this with `ALLOWED_ACTOR_IDS`. 3. Monitor the audit logs for new account tokens, and review tokens that hold `fe.secure.write.delegation`. 4. Give `fe.secure.write.tenantApiTokens` only to users who are allowed to create actors, and treat it like `fe.secure.write.delegation`. This check protects only your own API. Frontegg APIs accept any delegated token that passed the exchange, so item 4 is the control for them. The check also refuses [user impersonation](/ciam/guides/management/manage-users/impersonation) tokens, because their `act.sub` is the impersonating user. If you use impersonation, add those user IDs to the list or handle them separately. If you have many accounts (one actor per account), keep the list in a datastore, not in an environment variable. The permission `fe.secure.write.actorToken` (**Create or revoke actor tokens**) belongs to user impersonation. This flow does not use it. ### Step 1: Get the actor token For an **account access token**, the actor token is the token value that you received when you created it. Skip to step 2. For an **account client credentials token**, [authenticate with the token's client ID and secret](/ciam/api/identity/api-token/authenticationapitokencontrollerv2_authapitoken). The JWT is in the `access_token` field: ```shell ACTOR_TOKEN=$(curl -sS -X POST "$FRONTEGG_DOMAIN/identity/resources/auth/v2/api-token" \ -H 'Content-Type: application/json' \ -d "{\"clientId\": \"$ACTOR_CLIENT_ID\", \"secret\": \"$ACTOR_SECRET\"}" \ | jq -er '.access_token // error(tostring)') && export ACTOR_TOKEN ``` The `client_credentials` grant on `$FRONTEGG_DOMAIN/oauth/token` returns the same JWT. Request a new actor JWT when the old one expires (see `expires_in`), and cache it until then. ### Step 2: Get the subject token The subject token is the user's access token from a normal Frontegg login, for example from the hosted login box or a Frontegg SDK. Your frontend sends it to your backend in the `Authorization` header, and your backend passes it to the token broker with the task. For a first test, you can sign in a test user with email and password. This works only when email and password login is enabled for the environment and the test user does not need MFA: ```shell SUBJECT_TOKEN=$(curl -sS -X POST "$FRONTEGG_DOMAIN/identity/resources/auth/v1/user" \ -H 'Content-Type: application/json' \ -d '{"email": "test-user@example.com", "password": "TEST_USER_PASSWORD"}' \ | jq -er '.accessToken // error(tostring)') && export SUBJECT_TOKEN ``` The subject token must: - Be a valid, unexpired JWT that the same environment signed. - Contain the user's `email` claim. Frontegg finds the user by email, so users without an email (for example, phone-only or username-only users) cannot be subjects. - Have the same `tenantId` as the actor token. - Be a user login token (`type` is `userToken`) with no `act` claim. If you exchange a delegated token or an impersonation token, the new token replaces the old `act` claim and the first actor is lost. Frontegg does not check this, so check it in your token broker. ### Step 3: Exchange the tokens Send both tokens to the token endpoint, `$FRONTEGG_DOMAIN/oauth/token`. This is the `token_endpoint` in your discovery document (`$FRONTEGG_DOMAIN/.well-known/openid-configuration`). Frontegg also accepts the same path with a `/frontegg` prefix (`/frontegg/oauth/token`), which some other guides use. Authenticate the request with your environment Client ID and API key, in a Basic `Authorization` header or as `client_id` and `client_secret` in the body. Prefer the Basic header: RFC 6749 does not recommend credentials in the body. Send the body form-encoded, as RFC 6749 specifies. ```shell DELEGATED_TOKEN=$(curl -sS -X POST "$FRONTEGG_DOMAIN/oauth/token" \ -u "$FRONTEGG_CLIENT_ID:$FRONTEGG_API_KEY" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d "subject_token=$SUBJECT_TOKEN" \ -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \ -d "actor_token=$ACTOR_TOKEN" \ -d 'actor_token_type=urn:ietf:params:oauth:token-type:access_token' \ | jq -er '.access_token // error(tostring)') && export DELEGATED_TOKEN ``` ```shell DELEGATED_TOKEN=$(curl -sS -X POST "$FRONTEGG_DOMAIN/oauth/token" \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \ -d "client_id=$FRONTEGG_CLIENT_ID" \ -d "client_secret=$FRONTEGG_API_KEY" \ -d "subject_token=$SUBJECT_TOKEN" \ -d 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \ -d "actor_token=$ACTOR_TOKEN" \ -d 'actor_token_type=urn:ietf:params:oauth:token-type:access_token' \ | jq -er '.access_token // error(tostring)') && export DELEGATED_TOKEN ``` To see the result, print the claims of the delegated token: ```shell node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')))" "$DELEGATED_TOKEN" ``` The output includes `act: { sub: '...', type: 'delegation' }`. | Parameter | Required by Frontegg | Value | | --- | --- | --- | | `grant_type` | Yes | `urn:ietf:params:oauth:grant-type:token-exchange` | | `subject_token` | Yes | The subject token from step 2 | | `actor_token` | Yes | The actor token from step 1. RFC 8693 makes it optional, but Frontegg requires it for delegation. | | `subject_token_type` | No | `urn:ietf:params:oauth:token-type:access_token`. RFC 8693 requires it. Frontegg does not check it and always reads the subject token as an access token. | | `actor_token_type` | No | `urn:ietf:params:oauth:token-type:access_token`. RFC 8693 requires it when you send `actor_token`. Frontegg does not check it. | | `requested_token_type`, `audience`, `resource`, `scope` | No | Ignored. The delegated token is always an access token with the user's full roles and permissions. | Call the token endpoint only from your backend This request contains your environment API key. Never send it from a browser, a mobile app, an AI agent or any code that a user can read. br #### Response ```json { "token_type": "Bearer", "access_token": "eyJhbGciOiJSUzI1NiIs...", "id_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "f1c7b2e4-...", "expires_in": 3412 } ``` | Field | Description | | --- | --- | | `access_token` | The delegated token. The actor uses it as a Bearer token when it calls your APIs. | | `id_token` | The subject token that you sent, returned without change. It is **not** an OpenID Connect ID token. Ignore it. | | `refresh_token` | Do not use it. Discard it at once. See [Token lifetime and refresh](#token-lifetime-and-refresh). | | `expires_in` | Seconds until the delegated token expires. | Frontegg differs from RFC 8693 and RFC 6749 in the response: - The response does not include `issued_token_type`. The issued token is always an access token. - Errors use the Frontegg body shape, not the RFC 6749 `error` and `error_description` fields. - A denial by a delegation rule (`Delegation is not allowed`, `Delegation is not allowed for different tenants`) returns `403`, where RFC 6749 uses `400`. Invalid tokens return `400`. Failed client authentication returns `401`, as RFC 6749 permits, but without a `WWW-Authenticate` header. ```json { "errors": ["Delegation is not allowed"] } ``` ### Step 4: Call your API with the delegated token The actor sends the delegated token in the `Authorization: Bearer` header. The delegated token is a normal Frontegg user token for the subject user, plus an `act` (actor) claim: ```json { "sub": "d3b07384-user-id", "email": "jane@example.com", "tenantId": "9f8e7d6c-account-id", "roles": ["Admin"], "permissions": ["fe.secure.read.users", "reports.read"], "type": "userToken", "sid": "5a4b3c2d-session-id", "act": { "sub": "c1d2e3f4-actor-token-id", "type": "delegation" }, "aud": "your-environment-client-id", "iss": "https://app-xxxx.frontegg.com", "exp": 1760000000 } ``` - `roles` and `permissions` appear only when your environment adds them to the JWT. - `aud` is your environment Client ID, or your application ID when your environment uses the application ID as the audience. See [Token claims](/ciam/guides/security-center/token-management/claims). - `iss` is the domain that you called the token endpoint on. Call it on the domain where your users sign in, so that delegated tokens have the same `iss` as normal user tokens. | `act` field | Value | | --- | --- | | `act.sub` | For an account client credentials token: its `clientId`. For an account access token: the access token's ID. For a user actor: the actor's user ID. | | `act.type` | `delegation` for this flow. Frontegg adds this field. RFC 8693 does not define it. | | `act.email` | For a user actor only: the actor's email. Frontegg adds this field. | Frontegg does not set `act.iss`, does not nest `act` claims for chains, and does not support the `may_act` claim. The `fe.secure.write.delegation` permission controls who can act. Check that act exists, not its type [User impersonation](/ciam/guides/management/manage-users/impersonation) tokens also carry an `act` claim, with `act.type` set to `impersonation`. Both flows name an actor in `act`. A token that Frontegg refreshed can keep `act.sub` but show `act.type` as `none`. Treat any token with an `act` claim as "someone acts for this user", and do not rely on `act.type`. br #### Validate the delegated token in your API Validate the delegated token like any Frontegg access token. Check the signature against your environment's JWKS (`$FRONTEGG_DOMAIN/.well-known/jwks.json`), the issuer, the audience and the expiry. Check that the token's account matches the account of the resource. Then read the `act` claim. This example uses Node.js 20 or later and the `jose` package (`npm i jose`). Set `ALLOWED_ACTOR_IDS` to a comma-separated list of the actor token IDs that you created (see [Accept only your own actors](#accept-only-your-own-actors)). Set `FRONTEGG_APP_ID` only if your environment uses the application ID as the audience. If you have more than one application, list every application ID that can call your API in `AUDIENCES`. ```javascript // auth.mjs import { createRemoteJWKSet, jwtVerify } from 'jose'; const { FRONTEGG_DOMAIN, FRONTEGG_CLIENT_ID, FRONTEGG_APP_ID, ALLOWED_ACTOR_IDS = '' } = process.env; if (!FRONTEGG_DOMAIN || !FRONTEGG_CLIENT_ID) { throw new Error('Set FRONTEGG_DOMAIN and FRONTEGG_CLIENT_ID first'); } const ALLOWED_ACTORS = new Set(ALLOWED_ACTOR_IDS.split(',').map((id) => id.trim()).filter(Boolean)); const JWKS = createRemoteJWKSet(new URL(`${FRONTEGG_DOMAIN}/.well-known/jwks.json`)); const AUDIENCES = [FRONTEGG_CLIENT_ID, FRONTEGG_APP_ID].filter(Boolean); // expectedTenantId: the account that owns the resource of this request export async function authenticate(req, expectedTenantId) { const header = req.headers.authorization ?? ''; if (!header.startsWith('Bearer ')) { return { status: 401 }; } let payload; try { ({ payload } = await jwtVerify(header.slice('Bearer '.length), JWKS, { issuer: FRONTEGG_DOMAIN, audience: AUDIENCES, })); } catch { return { status: 401 }; } if (payload.tenantId !== expectedTenantId) { return { status: 403 }; } // Refuse actors that you did not create if (payload.act && !ALLOWED_ACTORS.has(payload.act.sub)) { return { status: 403 }; } return { status: 200, userId: payload.sub, tenantId: payload.tenantId, permissions: payload.permissions ?? [], actor: payload.act ?? null, // set for delegation (impersonation is refused above unless listed) }; } ``` Call it from your request handler, for example with `node:http`: ```javascript // server.mjs import { createServer } from 'node:http'; import { authenticate } from './auth.mjs'; createServer(async (req, res) => { const accountId = req.headers['x-account-id']; // the account that owns the requested resource const auth = await authenticate(req, accountId); if (auth.status !== 200) { res.writeHead(auth.status).end(); return; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ user: auth.userId, actor: auth.actor?.sub ?? null })); }).listen(3000); ``` To test it, start the server in the background with your actor on the list, then call it with the delegated token from step 3. `ACCOUNT_ID` must be the user's account (the `tenantId` of the delegated token): ```shell ALLOWED_ACTOR_IDS="$ACTOR_CLIENT_ID" node server.mjs & curl -i -H "Authorization: Bearer $DELEGATED_TOKEN" -H "x-account-id: $ACCOUNT_ID" http://localhost:3000 ``` The response is `200` with `{"user":"","actor":""}`. Use the `act` claim to: - **Audit** — log the user (`sub`) and the actor (`act.sub`) on every action. Use your list of actor token IDs to show agent names. - **Restrict** — refuse actions that a person must do, even when the user has the permission. For example, refuse to change billing details when `actor` is not `null`. #### Complete example This script is the token broker part of steps 1 to 3, with the checks that Frontegg does not do. Set the environment values, set `SUBJECT_TOKEN` (see step 2), then run `npm i jose` and `node obo-example.mjs`. ```javascript // obo-example.mjs (Node.js 20 or later) import { decodeJwt } from 'jose'; const REQUIRED = ['FRONTEGG_DOMAIN', 'FRONTEGG_CLIENT_ID', 'FRONTEGG_API_KEY', 'ACTOR_CLIENT_ID', 'ACTOR_SECRET', 'SUBJECT_TOKEN']; const missing = REQUIRED.filter((name) => !process.env[name]); if (missing.length) { console.error(`Set these environment variables first: ${missing.join(', ')}`); process.exit(1); } const { FRONTEGG_DOMAIN, FRONTEGG_CLIENT_ID, FRONTEGG_API_KEY, ACTOR_CLIENT_ID, ACTOR_SECRET, SUBJECT_TOKEN } = process.env; async function postOrThrow(url, init) { const response = await fetch(url, { method: 'POST', ...init }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`${url} failed (${response.status}): ${JSON.stringify(body.errors ?? body)}`); } return body; } // step 1: get the actor token (account client credentials token) async function getActorToken() { const { access_token } = await postOrThrow(`${FRONTEGG_DOMAIN}/identity/resources/auth/v2/api-token`, { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: ACTOR_CLIENT_ID, secret: ACTOR_SECRET }), }); return access_token; } // step 2: refuse subject tokens that would hide an earlier actor function checkSubjectToken(subjectToken) { const subject = decodeJwt(subjectToken); if (subject.type !== 'userToken' || subject.act) { throw new Error('Subject token must be a user login token with no act claim'); } return subject; } // step 3: exchange the subject token and the actor token async function exchangeForDelegatedToken(subjectToken, actorToken) { const basic = Buffer.from(`${FRONTEGG_CLIENT_ID}:${FRONTEGG_API_KEY}`).toString('base64'); const { access_token, expires_in } = await postOrThrow(`${FRONTEGG_DOMAIN}/oauth/token`, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: `Basic ${basic}`, }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: subjectToken, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', actor_token: actorToken, actor_token_type: 'urn:ietf:params:oauth:token-type:access_token', }), }); // refresh_token is not used: never store or log it return { accessToken: access_token, expiresIn: expires_in }; } const subject = checkSubjectToken(SUBJECT_TOKEN); const actorToken = await getActorToken(); const { accessToken, expiresIn } = await exchangeForDelegatedToken(SUBJECT_TOKEN, actorToken); // Refuse a delegated token for another account than the actor's account const claims = decodeJwt(accessToken); const actorTenantId = decodeJwt(actorToken).tenantId; if (claims.tenantId !== actorTenantId || claims.tenantId !== subject.tenantId) { throw new Error('Delegated token is for another account: discard it'); } // step 4: give accessToken to the agent. Here we only print the claims. console.log({ user: claims.sub, tenantId: claims.tenantId, act: claims.act, expiresIn }); ``` `decodeJwt` does not check the signature. The token broker uses it only to check tokens that it got from Frontegg over TLS. Your API must verify the token as shown in [Validate the delegated token in your API](#validate-the-delegated-token-in-your-api). ### Token lifetime and refresh - By default, the delegated token expires at the same time as the subject token: Frontegg copies the subject token's `exp`. A JWT template expiration, a JWT generation prehook or a temporary user expiration can change this value. - **Refreshing a delegated token is not supported.** The `refresh_token` in the response does not work with `grant_type=refresh_token` on `/oauth/token`. Tokens refreshed through other routes can lose `act.type: delegation`. - The `refresh_token` is still a live credential. It lives for the environment's refresh token lifetime, which is usually much longer than the delegated token, and disabling delegation does not stop it. Discard it at once and never store or log it. If one leaks, end that session: while the delegated token is valid, call [Delete single user's session](/ciam/api/identity/user-sessions/usersessionscontrollerv1_deleteusersession) on `$FRONTEGG_DOMAIN` with the delegated token, and use the `sid` claim as the ID (see the commands after this list). After it expires, ask the user to sign out of all sessions ([Delete all user sessions](/ciam/api/identity/user-sessions/usersessionscontrollerv1_deletealluseractivesessions)). - To keep acting for the user after the delegated token expires, run the exchange again with a valid subject token. Your app keeps its own user session alive (for example, with the Frontegg SDK) and passes a new subject token to the token broker for each part of a long task. - When the user's session ends, your app cannot get new subject tokens, so the agent stops when the last subject token expires. For work that must run without the user, use an account client credentials token or account access token directly, without delegation. To end the session of a leaked `refresh_token` while the delegated token is valid: ```shell SID=$(node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')).sid)" "$DELEGATED_TOKEN") curl -sS -X DELETE "$FRONTEGG_DOMAIN/identity/resources/users/sessions/v1/me/$SID" \ -H "Authorization: Bearer $DELEGATED_TOKEN" ``` #### Sessions Each exchange creates a new session for the user. It appears in the user's active sessions and counts toward the session limit of the environment. When the user goes over the limit, Frontegg ends the user's **oldest** sessions, which can sign the user out of your app. Exchange once per task and reuse the delegated token until it expires. Do not exchange for every API call. ### Rules and limits | Rule | Detail | | --- | --- | | Same account only | The `tenantId` of the subject token and of the actor token must match. | | Issued account | Frontegg issues the delegated token for the user's **current active account**, not for the account in the subject token. See [Users in more than one account](#users-in-more-than-one-account). | | Permission required | The actor must hold `fe.secure.write.delegation`. | | Full user permissions | The delegated token has all the user's roles and permissions, including Frontegg `fe.secure.*` management permissions. An agent that acts for an admin can manage users, roles and SSO through the Frontegg APIs. | | No MFA, step-up or device check | The exchange does not ask the user for MFA and does not run step-up or device checks. The user already signed in to get the subject token. | | Subject token after logout | A subject token can be exchanged until it expires, even after the user signs out. Keep subject tokens short-lived (set a short JWT expiration). | | One hop | The `act` claim names only the last actor. | | Bearer token | Delegated tokens are not bound with DPoP, even when DPoP is on for the application. | | Rate limit | Token exchange shares the `oauth/token` rate limit. See [Rate limits](/ciam/guides/env-settings/rate-limits). | #### Users in more than one account If a user in more than one account switched accounts after they got the subject token, the delegated token can be for another account, with that account's roles. Check in your token broker that the delegated `tenantId` equals the actor's `tenantId`, and discard the token if not (the [complete example](#complete-example) does this). Your API must also check `tenantId` against the resource. ### Security notes - **Protect the environment API key.** Keep the exchange in one token broker service and store the key in a secret manager. - **Limit who holds the delegation permission.** Put `fe.secure.write.delegation` in a dedicated role with a protected level, set its classification type to **Never**, give it only to actor tokens, and keep it and the `fe.secure.*` and `fe.secure.write.*` wildcards out of every human role. - **Give each agent its own actor token.** Then `act.sub` tells you which agent acted. - **Accept only your own actors.** Refuse tokens whose `act.sub` is not on your list of actor token IDs, and limit who can create account tokens. See [Accept only your own actors](#accept-only-your-own-actors). - **Check tokens in the token broker.** Refuse subject tokens that already have `act`, and discard delegated tokens whose `tenantId` is not the actor's account. If a task does not need management access, also refuse subject tokens that hold `fe.secure.write.*` permissions: Frontegg cannot narrow the delegated token. - **Protect subject tokens in transit.** Send them only over authenticated, encrypted internal channels. Do not put them in task queues or logs in clear text. - **Do not store delegated tokens or refresh tokens.** Get a new delegated token for each task and drop it when the task ends. - **Check `act` in sensitive endpoints.** Decide which actions an actor may never do for a user, and enforce that in your API. - **Know what stops an actor.** Disabling delegation (Setup 1) stops all new exchanges at once. Deleting an access token actor stops its exchanges at once. Deleting a client credentials token stops new actor JWTs, but its issued JWTs work until they expire. Delegated tokens that were already issued stay valid until they expire. A leaked `refresh_token` works until you end its session (see [Token lifetime and refresh](#token-lifetime-and-refresh)). ### Troubleshooting Before you check the table, decode the subject token and the actor token (for example, with `decodeJwt` from `jose`) and check: 1. Both tokens have the same `tenantId`. 2. Neither token has an `exp` in the past. 3. Both tokens come from the same environment as your Client ID and API key, and you call the token endpoint on that environment's domain. 4. The actor `type` is one of the types in [Setup 2](#setup-2-give-the-actor-the-delegation-permission), and it holds `fe.secure.write.delegation`. For a client credentials token, look in its `permissions` claim. For an access token, the claim holds only the placeholder `FETCH-PERMISSIONS-BY-API`, so check the token's role (or the user's role) in the portal. 5. The subject token is a `userToken` with an `email` claim, and you did not swap the subject token and the actor token. 6. `GET` on the delegation configuration returns `{ "enabled": true }`. Match the message in the `errors` array. Do not rely on error code fields for this flow. If **your own API** returns `401` or `403` with an empty body, the error comes from your validation code, not from Frontegg. Frontegg errors come only from the token endpoint and always have an `errors` array. Check that `ALLOWED_ACTOR_IDS` contains the token's `act.sub`, that the account of the resource equals the token's `tenantId`, and that `iss`, `aud` and `exp` are valid. | HTTP status | Error message | Cause | How to fix | | --- | --- | --- | --- | | 400 | `grant_type must be one of the following values: ...` | Typo in `grant_type` | Send `urn:ietf:params:oauth:grant-type:token-exchange`. | | 400 | `subject_token should not be empty` or `actor_token should not be empty` | Missing token in the request | Send both tokens. | | 400 | `Input token invalid` | The subject token is expired, malformed or signed by another environment | Get a new subject token from the same environment. | | 400 | `Actor token invalid` | The actor token is expired, malformed or signed by another environment, or it is the environment token | Get a new actor token (step 1) from the same environment. Use an account token from Setup 2, never the environment token. | | 400 | `Actor user not found` | The user in a user actor token no longer exists | Use an existing user or an account token as the actor. | | 400 | `Access token revoked` | A user (personal) access token actor was deleted | Create a new access token with the actor role. | | 400 | `Could not extract user info from token` | The subject token has no `email` claim | Use a user who has an email. | | 400 | `Invalid user` | No user with the subject token's email exists in this environment | Check that the user exists in this environment. If the user's email changed, ask the user to sign in again. | | 400 | `Failed to generate token` | Frontegg could not issue the token, for example because the user's account (tenant) is locked | Unlock the account. If the account is not locked, contact Frontegg support. | | 401 | `Client ID and ClientSecret are missing` | No Basic header and no `client_id` and `client_secret` | Send the environment Client ID and API key. | | 401 | `Invalid client authentication` | Wrong Client ID or API key, an application ID and secret instead of the environment credentials, or a domain of another environment | Use the environment Client ID and API key from **Keys & domains**, and the domain of the same environment. | | 403 | `OAuth is disabled` | OAuth is off for the environment | Contact Frontegg support to enable OAuth for the environment. | | 403 | `Delegation is not allowed` | Delegation is off, or the actor does not hold `fe.secure.write.delegation`, or a user actor is not a member of the account, or the subject and actor tokens are swapped | Check in this order: `GET` the delegation configuration (Setup 1), then the actor's permission (checklist item 4), then the user actor's account membership, then the order of the two tokens. | | 403 | `Delegation is not allowed for different tenants` | The subject and the actor are in different accounts | Use an actor token for the user's account. | | 404 | `Delegation configuration does not exist` | `GET` on the delegation configuration before the first `POST` | Enable delegation (Setup 1). | | 429 | Rate limit response | Too many requests to `oauth/token` | Wait and retry with backoff. Cache actor JWTs and reuse delegated tokens for the whole task. | | 500 | Server error, or `Failed to exchange token` | An account client credentials actor JWT whose `permissions` claim is missing or holds only `FETCH-PERMISSIONS-BY-API`, a deleted account access token actor, or a temporary Frontegg error | Make sure the environment adds permissions to the JWT and that the actor token still exists. Then retry, and contact Frontegg support if it continues. | ### Related - [M2M tokens](/ciam/guides/authentication/m2m/overview) - [M2M management](/ciam/guides/authentication/m2m/management) - [User impersonation](/ciam/guides/management/manage-users/impersonation) - [AI agents with Frontegg entitlements](/ciam/guides/frontegg-ai/agent-authorization) - [Permissions table](/ciam/guides/authorization/rbac/permissions-table) - [Delegation API reference](/ciam/api/identity/delegation/delegationconfigurationcontrollerv1_createorupdatedelegationconfiguration)