Skip to content
Last updated

Token exchange (on-behalf-of)

Token exchange (RFC 8693) 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.
  • Roles and permissions turned on: [ENVIRONMENT] → Configurations → Entitlements → Roles. See Roles.
  • For the samples: curl, jq and Node.js 20 or later.

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 and give the actor the delegation permission. Then each task runs steps 1 to 4:

 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                  |

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).

VariableValue
FRONTEGG_DOMAINYour Frontegg or custom domain, with no trailing slash
FRONTEGG_API_HOSTThe Management API host for your region
FRONTEGG_CLIENT_IDKeys & domains → Client ID
FRONTEGG_API_KEYKeys & domains → API key
ACTOR_CLIENT_ID, ACTOR_SECRETThe actor token's client ID and secret. Setup 2 can set them for you.
ALLOWED_ACTOR_IDSFor your API only: a comma-separated list of the actor IDs that you created. See Accept only your own actors.
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.

FRONTEGG_API_HOST depends on your region: EU, US, CA or AU. See 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. There is no portal setting for it.

First, get an environment token. The token is in the token field of the response:

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:

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.
  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 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:

ActorJWT type claimWhat stops it
Account client credentials tokentenantApiTokenDeleting the token stops new actor JWTs. Actor JWTs that were already issued work until they expire.
Account access tokentenantAccessTokenDeleting the token stops its exchanges at once.
User (personal) client credentials tokenuserApiTokenSame as account client credentials token. A role change takes effect when the current actor JWT expires.
User (personal) access tokenuserAccessTokenSame as account access token.
Another user's access token (from login)userTokenA 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 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:

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.


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.


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 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 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. The JWT is in the access_token field:

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:

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.

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

To see the result, print the claims of the delegated token:

node -e "console.log(JSON.parse(Buffer.from(process.argv[1].split('.')[1], 'base64url')))" "$DELEGATED_TOKEN"

The output includes act: { sub: '...', type: 'delegation' }.

ParameterRequired by FronteggValue
grant_typeYesurn:ietf:params:oauth:grant-type:token-exchange
subject_tokenYesThe subject token from step 2
actor_tokenYesThe actor token from step 1. RFC 8693 makes it optional, but Frontegg requires it for delegation.
subject_token_typeNourn: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_typeNourn: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, scopeNoIgnored. 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.


Response

{
  "token_type": "Bearer",
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "f1c7b2e4-...",
  "expires_in": 3412
}
FieldDescription
access_tokenThe delegated token. The actor uses it as a Bearer token when it calls your APIs.
id_tokenThe subject token that you sent, returned without change. It is not an OpenID Connect ID token. Ignore it.
refresh_tokenDo not use it. Discard it at once. See Token lifetime and refresh.
expires_inSeconds 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.
{
  "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:

{
  "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.
  • 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 fieldValue
act.subFor 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.typedelegation for this flow. Frontegg adds this field. RFC 8693 does not define it.
act.emailFor 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 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.


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). 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.

// 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:

// 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):

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":"<user ID>","actor":"<actor client ID>"}.

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.

// 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.


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 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).
  • 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:

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

RuleDetail
Same account onlyThe tenantId of the subject token and of the actor token must match.
Issued accountFrontegg 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.
Permission requiredThe actor must hold fe.secure.write.delegation.
Full user permissionsThe 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 checkThe 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 logoutA 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 hopThe act claim names only the last actor.
Bearer tokenDelegated tokens are not bound with DPoP, even when DPoP is on for the application.
Rate limitToken exchange shares the oauth/token rate limit. See 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 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.
  • 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).

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, 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 statusError messageCauseHow to fix
400grant_type must be one of the following values: ...Typo in grant_typeSend urn:ietf:params:oauth:grant-type:token-exchange.
400subject_token should not be empty or actor_token should not be emptyMissing token in the requestSend both tokens.
400Input token invalidThe subject token is expired, malformed or signed by another environmentGet a new subject token from the same environment.
400Actor token invalidThe actor token is expired, malformed or signed by another environment, or it is the environment tokenGet a new actor token (step 1) from the same environment. Use an account token from Setup 2, never the environment token.
400Actor user not foundThe user in a user actor token no longer existsUse an existing user or an account token as the actor.
400Access token revokedA user (personal) access token actor was deletedCreate a new access token with the actor role.
400Could not extract user info from tokenThe subject token has no email claimUse a user who has an email.
400Invalid userNo user with the subject token's email exists in this environmentCheck that the user exists in this environment. If the user's email changed, ask the user to sign in again.
400Failed to generate tokenFrontegg could not issue the token, for example because the user's account (tenant) is lockedUnlock the account. If the account is not locked, contact Frontegg support.
401Client ID and ClientSecret are missingNo Basic header and no client_id and client_secretSend the environment Client ID and API key.
401Invalid client authenticationWrong Client ID or API key, an application ID and secret instead of the environment credentials, or a domain of another environmentUse the environment Client ID and API key from Keys & domains, and the domain of the same environment.
403OAuth is disabledOAuth is off for the environmentContact Frontegg support to enable OAuth for the environment.
403Delegation is not allowedDelegation 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 swappedCheck 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.
403Delegation is not allowed for different tenantsThe subject and the actor are in different accountsUse an actor token for the user's account.
404Delegation configuration does not existGET on the delegation configuration before the first POSTEnable delegation (Setup 1).
429Rate limit responseToo many requests to oauth/tokenWait and retry with backoff. Cache actor JWTs and reuse delegated tokens for the whole task.
500Server error, or Failed to exchange tokenAn 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 errorMake sure the environment adds permissions to the JWT and that the actor token still exists. Then retry, and contact Frontegg support if it continues.