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
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,jqand Node.js 20 or later.
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):
- Your app sends the task and the subject token to the token broker over an authenticated, encrypted internal channel.
- 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.
- The agent calls your API with the delegated token. It never sees the API key, the actor secret or the subject token.
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. |
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.
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_TOKENThen 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 }.
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.
- 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 keepfe.governance.assignEqualLevelRolesout of human roles. Superusers and the environment token are not limited by levels. See Roles. - Add the permission
fe.secure.write.delegationto that role. Copy the role ID from the role details, or get it from the Get roles API with the environment token. - In [ENVIRONMENT] → Configurations → Entitlements → Permissions, set the Classification type of
fe.secure.write.delegationto Never, so that accounts cannot add it to their own custom roles. - 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 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
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
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.
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:
- Record the ID of every actor token that you create (the
clientIdof a client credentials token, or the ID of an access token). - In your API, refuse any token whose
act.subis not on that list. The validation sample does this withALLOWED_ACTOR_IDS. - Monitor the audit logs for new account tokens, and review tokens that hold
fe.secure.write.delegation. - Give
fe.secure.write.tenantApiTokensonly to users who are allowed to create actors, and treat it likefe.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.
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_TOKENThe 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.
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_TOKENThe subject token must:
- Be a valid, unexpired JWT that the same environment signed.
- Contain the user's
emailclaim. 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
tenantIdas the actor token. - Be a user login token (
typeisuserToken) with noactclaim. If you exchange a delegated token or an impersonation token, the new token replaces the oldactclaim and the first actor is lost. Frontegg does not check this, so check it in your token broker.
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_TOKENTo 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' }.
| 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
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.
{
"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. |
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
erroranderror_descriptionfields. - A denial by a delegation rule (
Delegation is not allowed,Delegation is not allowed for different tenants) returns403, where RFC 6749 uses400. Invalid tokens return400. Failed client authentication returns401, as RFC 6749 permits, but without aWWW-Authenticateheader.
{
"errors": ["Delegation is not allowed"]
}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
}rolesandpermissionsappear only when your environment adds them to the JWT.audis your environment Client ID, or your application ID when your environment uses the application ID as the audience. See Token claims.issis 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 sameissas 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
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 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:3000The 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
actoris notnull.
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.
- 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_tokenin the response does not work withgrant_type=refresh_tokenon/oauth/token. Tokens refreshed through other routes can loseact.type: delegation. - The
refresh_tokenis 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_DOMAINwith the delegated token, and use thesidclaim 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"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.
| 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. |
| 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. |
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.
- 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.delegationin a dedicated role with a protected level, set its classification type to Never, give it only to actor tokens, and keep it and thefe.secure.*andfe.secure.write.*wildcards out of every human role. - Give each agent its own actor token. Then
act.subtells you which agent acted. - Accept only your own actors. Refuse tokens whose
act.subis 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 whosetenantIdis not the actor's account. If a task does not need management access, also refuse subject tokens that holdfe.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
actin 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_tokenworks until you end its session (see Token lifetime and refresh).
Before you check the table, decode the subject token and the actor token (for example, with decodeJwt from jose) and check:
- Both tokens have the same
tenantId. - Neither token has an
expin the past. - 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.
- The actor
typeis one of the types in Setup 2, and it holdsfe.secure.write.delegation. For a client credentials token, look in itspermissionsclaim. For an access token, the claim holds only the placeholderFETCH-PERMISSIONS-BY-API, so check the token's role (or the user's role) in the portal. - The subject token is a
userTokenwith anemailclaim, and you did not swap the subject token and the actor token. GETon 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. |