On this page
Set up imported AI agent token exchange
Okta for AI Agents secures AI agents with delegated user identity. When a user authenticates with Okta to access the agentic app, the app exchanges the user's identity token for a scoped access token. The AI agent can then call Okta-protected APIs on the user's behalf.
In this guide, learn how to configure token exchange for imported AI agents, and review the token exchange with a test app.
Note: To enable AI agent token exchange, you must first subscribe to Okta for AI Agents. Contact your Okta account team to enable the feature.
Learning outcomes
- Understand Okta's two-step Cross App Access (XAA) token exchange flow for AI agents.
- Understand how to set up the token exchange flow.
- Test the imported AI agent token exchange flow.
What you need
- An Okta org that's subscribed to Okta for AI Agents.
- An Okta user account with the super admin role.
Overview
Okta's token exchange uses two API calls. The user's ID token is exchanged for an ID-JAG at the org authorization server. The ID-JAG is then exchanged for a scoped access token at the custom authorization server. The calling app passes that token to the AI agent.
For a diagram and step-by-step description of this flow, see Token Exchange flow in Set up AI agent token exchange.
Note: No gateway or proxy is involved. The calling app owns the full token exchange. The AI agent receives a ready-to-use access token.
The machine identity that authenticates token exchange requests is the AI agent imported in the Admin Console. The AI agent authenticates both steps of the exchange.
Supported platforms
Okta supports the following AI agent platforms:
| Provider | Platform | Guide |
|---|---|---|
| Amazon Web Services | AWS Bedrock Classic AI agents | AWS Bedrock Classic AI agents guide |
| Amazon Web Services | AWS Bedrock AgentCore | AWS Bedrock AgentCore guide |
| Salesforce | Agentforce | Salesforce Agentforce guide |
Set up the imported AI agent token flow
To configure token exchange for imported AI agents, you must complete the following configurations:
Add a custom scope for your custom authorization server.
Import an AI agent with RSA key-pair authentication, or register your AI agent manually.
- You can automatically create an OIDC web app integration for user sign-on.
- You can select a previously created app integration for user sign-on.
Configure the access policy to allow the JWT bearer grant type.
Complete the token exchange flow with Okta APIs.
After these configurations, you can create a test app to demonstrate this flow. See Create an app to test the token exchange flow.
Add a custom scope for your custom authorization server
Your custom authorization server requires a custom scope for the AI agent token exchange. You can use the default custom authorization server or create your own. See Create an authorization server.
Note: The ID-JAG exchange strips system scopes (
openid,profile,invalid_scopeerror. Use a custom scope.
- In the Admin Console, go to Security > API.
- On the Authorization Servers tab, select the name of your authorization server, and then select Scopes.
- Select Scopes and then Add Scope.
- Enter a Name, for example,
xaa:read. - Optional. Enter a Display phrase, for example, "Cross App Access (XAA read-only scope)."
- Optional. Enter a Description, for example, "This scope allows AI agent token exchange."
- Click Save.
See Create Scopes.
Import your AI Agent
The AI agent is the machine identity that your calling app uses to sign token exchange requests. Import your AI agent following steps in AI agent Imports (opens new window).
The AI agent identity is distinct from the OIDC web app integration, which signs users in and issues the ID token. The AI agent identity authenticates both steps of the exchange.
In a real integration, you import the AI agent that you've already built, for example, a live Amazon Bedrock or Salesforce Agentforce AI agent. Importing the AI agent doesn't fully configure it for the token exchange. See Configure imported AI agents (opens new window) for the required post-import setup, and the platform-specific guides listed under Supported platforms for platform-specific import steps.
This guide isn't tied to a specific platform. To walk through the token exchange flow end-to-end, manually register an AI agent instead:
In the Admin Console, go to Directory > AI agents.
Click Register AI agent > Register manually.
In Profile, add a name and description for your AI agent, for example, "AI agent token exchange."
Optional. In Identifier (Recommended if available), select the AI agent builder platform, if available.
Optional. In External ID, add the external ID from your platform.
Click Next.
Under User access and authentication, ensure Allow users to access this agent is enabled, then select Create a new OIDC app linked to this AI Agent to create an OIDC SSO app instance to bind to the AI agent.
Note: You can use Select an existing app to choose an existing custom SSO app. This option is used for users to access the AI agent through an SSO SAML app.
Click Next.
Under Owners, add owners to the AI Agent. Add at least two owners. Click Save.
Select your AI Agent from the list of AI Agents, and click Client registration. Make a note of the Client ID available to the left of Okta-generated client ID.
Note: The
OIDC_CLIENT_IDvariable and theAGENT_CLIENT_IDvariable are set to this client ID value in the sample app. See Create your environment file.Under Client registration > Okta-generated client ID > Public/private key, click Configure. Click Generate secret. Copy and save the secret.
Click Okta under Step 1: Define where keys are managed, and then click Add public key and then Generate new key. Copy your public and private key and then click Done.
Copy the Key ID.
Note: The
OIDC_PRIVATE_KEY_JWKvariable and theAGENT_PRIVATE_KEY_JWKvariable are set to the private key value in the sample app. See Create your environment file.From Step 2: Activate for your AI agent, click Activate. Then click Enable. The Agent AI Client registration page now shows an
ACTIVEbadge next to Public/private key.Click Resource connections, and then Add resource connection. Select the Authorization server resource type, and then from Select Authorization server, select your custom authorization server, in this example, use
default. From The following OAuth scopes, select the custom scope you added previously, for example,xaa:read. Click Add.
Configure the OIDC integration app
After you create the AI Agent, configure the associated OIDC app that's bound to the agent.
- Select your AI Agent from the list of AI Agents, and click User access. Click Application > General. The OIDC app appears.
- On the General tab, click Edit on the General Settings tile. Update the Sign-in redirect URIs field. In this example, use
http://localhost:5000/callback. Click Save. - On the Assignments tab, click Assign to assign people or groups to this app. These users sign in to begin the token exchange flow.
- Ensure that the OIDC app is in an Active state. Click the dropdown next to the app name to activate the app.
Configure the access policy
After you create the AI Agent, configure your custom authorization server's access policy to authenticate your AI Agent.
- In the Admin Console, go to Security > API.
- On the Authorization Servers tab, select the name of an authorization server (
defaultif you're using the default custom authorization server). - Select Access Policies, and then edit an existing policy. If you need to add a policy, see Create access policies.
- Edit the default rule or create a rule, see Create Rules for each Access Policy.
- Enable grant type JWT Bearer.
- Save the rule and policy.
Complete the token exchange flow
Your app makes two API calls directly to Okta's token endpoints. The flow comprises the following two steps:
- Exchange the
id_tokenfor ID-JAG - Exchange the ID-JAG for an
access_token
Note: These two calls implement the Authorization server resource type described generically in Set up AI agent token exchange. The request and response shapes are the same. If you change scopes, grant types, or parameters here, check that guide too so the two stay in sync.
To test this flow, use the following curl calls with your configured data.
Use the Create an app to test the token exchange flow to demonstrate the full token exchange flow and display the ID token, ID-JAG token, and access token.
Exchange the ID token for ID-JAG
Call the org authorization server's /token endpoint. The client_assertion is signed with the agent's RSA private key.
Ensure that you update the following values in this call: {yourOktaDomain}, {signed JWT}, {user id_token}, and the audience URL. See the following parameter table.
To generate an ID token, see Create an app to obtain a test ID token.
Request
curl -X POST https://{yourOktaDomain}/oauth2/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion={signed JWT}" \
--data-urlencode "subject_token={user id_token}" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:id_token" \
--data-urlencode "requested_token_type=urn:ietf:params:oauth:token-type:id-jag" \
--data-urlencode "scope=xaa:read" \
--data-urlencode "audience=https://example.okta.com/oauth2/default"
| Parameter | Description and value |
|---|---|
| grant_type | Standard OAuth 2.0 token exchange grant. The value must be urn:ietf:params:oauth:grant-type:token-exchange. |
| client_assertion_type | The value must be urn:ietf:params:oauth:client-assertion-type:jwt-bearer. |
| client_assertion | A signed JWT used for client authentication. Sign the JWT using the key created during the AI Agent registration. For more information on building the JWT, see JWT with private key (opens new window). |
| subject_token_type | The value must be urn:ietf:params:oauth:token-type:id_token. |
| subject_token | A valid ID token associated with a signed-in user. |
| requested_token_type | The value must be urn:ietf:params:oauth:token-type:id-jag. |
| scope | A list of scopes at the resource app being requested. This defines the permissions for the final access token. Use xaa:read |
| audience | The issuer URL of the resource app's authorization server. |
Response
A successful response returns an id_jag token. Pass this token to the next step:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"issued_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"access_token": "eyJhbGciOiJIUzI1NiIsI...",
"token_type": "N_A",
"expires_in": 300
}
Exchange the ID-JAG for an access token
Call the custom authorization server's token endpoint. The client_assertion audience is the custom authorization server's token URL.
Ensure that you update the following values in this call: {yourOktaDomain}, {custom-as-id} (default in this example), {signed JWT}, and the {id_jag} token. See the following parameter table.
Request
curl -X POST https://{your-okta-domain}/oauth2/{custom-as-id}/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
--data-urlencode "client_assertion={signed-jwt}" \
--data-urlencode "assertion={id_jag}"
| Parameter | Description and value |
|---|---|
| grant_type | The value must be urn:ietf:params:oauth:grant-type:jwt-bearer |
| assertion | The ID-JAG received in the previous step's token exchange response. |
| client_assertion_type | The value must be urn:ietf:params:oauth:client-assertion-type:jwt-bearer. |
| client_assertion | A signed JWT used for client authentication. Sign the JWT using the key created during the AI Agent registration. For more information on building the JWT, see JWT with private key (opens new window). |
Response
The response contains the access token that the AI agent uses to access the resource server.
{
"token_type": "Bearer",
"expires_in": 3600,
"access_token": "eyJraWQiOiJoZnpMS3...tdBbjhHcIXF_OQCsUdkuPXQTaAeq8fQ",
"scope": "xaa:read"
}
Set up a Python environment
This project demonstrates how to set up and run standalone Python scripts using the uv package manager.
Install uv
Install uv using the official installer:
brew install uv
For more installation options (Windows, macOS without curl, and so on), see the uv installation guide (opens new window).
After installation, verify that it works:
uv --version
Set up the project
Go to the project directory:
cd /yourProject
Initialize the project with uv init:
uv init
After initialization, sync the environment:
uv sync
Install the required dependencies:
uv add python-dotenv flask requests "pyjwt[crypto]"
This adds the packages needed by the demo scripts.
Create an app to obtain a test ID token
This demo script obtains an ID token for testing. See Exchange the ID token for ID-JAG.
Create your environment file
Create a .env file. The demo script references the values in this file. Include the following details from your AI agent. Use your AI agent ID as the OIDC_CLIENT_ID, and the key ID and private key generated during AI Agent registration as OIDC_KEY_ID and OIDC_PRIVATE_KEY_JWK. Add:
OKTA_DOMAIN=https://{yourOktaDomain}
OIDC_CLIENT_ID={client_id}
OIDC_KEY_ID={yourKeyID}
OIDC_PRIVATE_KEY_JWK={yourPrivateKey}
For example:
# OIDC config for id-token-demo.py
# OKTA_DOMAIN must include https:// and have NO trailing slash
OKTA_DOMAIN=https://example.okta.com
OIDC_CLIENT_ID=wlpo1x....Vv6aZ1d7
OIDC_KEY_ID=98cfd0b41b99b68....fb8868188d2f5
OIDC_PRIVATE_KEY_JWK={"alg":"RS256","d":"QtPaeAww4ykVlxafEqZ7A..."}
Create the token demo file
Create a scripts folder at the root level of your project, and create a Python file, for example, id-token-demo.py. Copy the following Python code into the file and save.
# id-token-demo.py
import os, json, time, uuid, secrets
import jwt
from flask import Flask, redirect, request, session
import requests
from dotenv import load_dotenv
load_dotenv()
OKTA_DOMAIN = os.environ["OKTA_DOMAIN"]
OIDC_CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
OIDC_KEY_ID = os.environ["OIDC_KEY_ID"]
OIDC_PRIVATE_KEY_JWK = json.loads(os.environ["OIDC_PRIVATE_KEY_JWK"])
REDIRECT_URI = "http://localhost:5000/callback"
# PyJWT can't sign with a raw JWK dict. Convert it to a key object once.
OIDC_SIGNING_KEY = jwt.PyJWK.from_dict(OIDC_PRIVATE_KEY_JWK).key
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", secrets.token_hex(32))
def build_client_assertion(audience: str) -> str:
now = int(time.time())
return jwt.encode(
{
"iss": OIDC_CLIENT_ID,
"sub": OIDC_CLIENT_ID,
"aud": audience,
"iat": now,
"exp": now + 300,
"jti": str(uuid.uuid4()),
},
OIDC_SIGNING_KEY,
algorithm="RS256",
headers={"kid": OIDC_KEY_ID},
)
@app.route("/")
def index():
state = secrets.token_urlsafe(16)
session["oauth_state"] = state
return redirect(
f"{OKTA_DOMAIN}/oauth2/v1/authorize"
f"?response_type=code&client_id={OIDC_CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}&scope=openid+profile+email"
f"&state={state}"
)
@app.route("/callback")
def callback():
# Surface any error Okta sent back instead of crashing on a missing code.
if "error" in request.args:
return (
f"<pre>error: {request.args.get('error')}\n"
f"description: {request.args.get('error_description')}</pre>",
400,
)
# Validate state to protect against CSRF.
expected_state = session.pop("oauth_state", None)
if not expected_state or request.args.get("state") != expected_state:
return "<pre>error: state mismatch</pre>", 400
code = request.args.get("code")
if not code:
return "<pre>error: no authorization code returned</pre>", 400
token_url = f"{OKTA_DOMAIN}/oauth2/v1/token"
resp = requests.post(token_url, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": OIDC_CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(token_url),
})
if not resp.ok:
return f"<pre>token endpoint error ({resp.status_code}):\n{resp.text}</pre>", 400
id_token = resp.json().get("id_token", "")
return f"""\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Okta secures AI</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f6f8;
color: #1d1d21;
margin: 0;
padding: 2.5rem;
}}
h1 {{
font-size: 1.6rem;
font-weight: 600;
color: #00297a;
margin: 0 0 1.5rem;
}}
table {{
border-collapse: collapse;
width: 100%;
max-width: 960px;
background: #fff;
border: 1px solid #d7dae0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}}
th, td {{
text-align: left;
padding: 0.85rem 1rem;
border-bottom: 1px solid #e6e8ec;
vertical-align: top;
}}
th {{
background: #00297a;
color: #fff;
font-weight: 600;
}}
tr:last-child td {{
border-bottom: none;
}}
td.token-name {{
font-weight: 600;
white-space: nowrap;
width: 1%;
}}
td.token-value {{
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 0.85rem;
word-break: break-all;
}}
</style>
</head>
<body>
<h1>Okta secures AI</h1>
<table>
<thead>
<tr><th>Token</th><th>Value</th></tr>
</thead>
<tbody>
<tr>
<td class="token-name">ID token</td>
<td class="token-value">{id_token}</td>
</tr>
</tbody>
</table>
</body>
</html>"""
if __name__ == "__main__":
app.run(port=5000)
Run the demo file
Run the demo file:
uv run scripts/id-token-demo.py
Then open http://localhost:5000/ in your browser to start the sign-in flow. After you enter your Okta credentials, the ID token appears on the rendered page. You can use this ID token to test the token exchange flow API calls in Exchange the ID token for ID-JAG.
To see the full flow, create and run the following demo script.
Create an app to test the token exchange flow
Use the following Python Flask app to test the token exchange flow. It obtains an ID token and then performs the two-step authentication process as documented in Complete the token exchange flow.
Create your environment file
Create an .env file (or modify the .env from the previous section). The demo script references the values in this file. Include the following details from the remainder of your token exchange setup:
OKTA_DOMAIN=https://{yourOktaDomain}
OIDC_CLIENT_ID={client_id}
OIDC_KEY_ID={yourKeyID}
OIDC_PRIVATE_KEY_JWK={yourPrivateKey}
CUSTOM_AS={yourCustomAS}
AGENT_CLIENT_ID={yourAgentID}
AGENT_KEY_ID={yourAgentKID}
AGENT_PRIVATE_KEY_JWK={yourAgentPrivateKey}
For example:
# OIDC config for token-exchange-demo.py
# OKTA_DOMAIN must include https:// and have NO trailing slash
OKTA_DOMAIN=https://example.okta.com
OIDC_CLIENT_ID=0oazte....Vv6aZ1d7
OIDC_KEY_ID=98cfd0b41b99b68....fb8868188d2f5
OIDC_PRIVATE_KEY_JWK={"alg":"RS256","d":"QtPaeAww4ykVlxafEqZ7A..."}
CUSTOM_AS=default
AGENT_CLIENT_ID=wlpzx5jq6....zGJY1d7
AGENT_KEY_ID=98cfd0b41b99b68....fb8868188d2f5
AGENT_PRIVATE_KEY_JWK={"alg":"RS256","d":"QtPaeAww4ykVlxafEqZ7A..."}
Create the demo file
Create a scripts folder at the root level of your project, and create a Python file, for example, token-exchange-demo.py. Copy the following Python code into the file and save.
# token-exchange-demo.py
import os, json, time, uuid, secrets
import jwt
import requests
from flask import Flask, redirect, request, session
from dotenv import load_dotenv
load_dotenv()
# --- OIDC (user sign-in) ---
OKTA_DOMAIN = os.environ["OKTA_DOMAIN"]
OIDC_CLIENT_ID = os.environ["OIDC_CLIENT_ID"]
OIDC_KEY_ID = os.environ["OIDC_KEY_ID"]
OIDC_PRIVATE_KEY_JWK = json.loads(os.environ["OIDC_PRIVATE_KEY_JWK"])
REDIRECT_URI = "http://localhost:5000/callback"
# PyJWT can't sign with a raw JWK dict. Convert it to a key object once.
OIDC_SIGNING_KEY = jwt.PyJWK.from_dict(OIDC_PRIVATE_KEY_JWK).key
# --- Token exchange (agent on behalf of user) ---
CUSTOM_AS = os.environ["CUSTOM_AS"]
SCOPE = os.environ.get("SCOPE", "xaa:read")
AGENT_CLIENT_ID = os.environ["AGENT_CLIENT_ID"]
AGENT_KEY_ID = os.environ["AGENT_KEY_ID"]
AGENT_PRIVATE_KEY_JWK = json.loads(os.environ["AGENT_PRIVATE_KEY_JWK"])
AGENT_SIGNING_KEY = jwt.PyJWK.from_dict(AGENT_PRIVATE_KEY_JWK).key
def build_client_assertion(client_id: str, key_id: str, signing_key, audience: str) -> str:
now = int(time.time())
return jwt.encode(
{
"iss": client_id,
"sub": client_id,
"aud": audience,
"iat": now,
"exp": now + 300,
"jti": str(uuid.uuid4()),
},
signing_key,
algorithm="RS256",
headers={"kid": key_id},
)
def get_id_jag(id_token: str) -> str:
org_token_url = f"{OKTA_DOMAIN}/oauth2/v1/token"
custom_as_issuer = f"{OKTA_DOMAIN}/oauth2/{CUSTOM_AS}" # audience = AS issuer, not its token endpoint
resp = requests.post(org_token_url, data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(AGENT_CLIENT_ID, AGENT_KEY_ID, AGENT_SIGNING_KEY, org_token_url),
"subject_token": id_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:id_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:id-jag",
"scope": SCOPE,
"audience": custom_as_issuer,
})
if not resp.ok:
raise RuntimeError(f"id-jag exchange failed ({resp.status_code}) at {org_token_url}:\n{resp.text}")
return resp.json()["access_token"]
def get_access_token(id_jag: str) -> str:
custom_as_token_url = f"{OKTA_DOMAIN}/oauth2/{CUSTOM_AS}/v1/token"
resp = requests.post(custom_as_token_url, data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(AGENT_CLIENT_ID, AGENT_KEY_ID, AGENT_SIGNING_KEY, custom_as_token_url),
"assertion": id_jag,
})
if not resp.ok:
raise RuntimeError(f"access-token exchange failed ({resp.status_code}) at {custom_as_token_url}:\n{resp.text}")
return resp.json()["access_token"]
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", secrets.token_hex(32))
@app.route("/")
def index():
state = secrets.token_urlsafe(16)
session["oauth_state"] = state
return redirect(
f"{OKTA_DOMAIN}/oauth2/v1/authorize"
f"?response_type=code&client_id={OIDC_CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}&scope=openid+profile+email"
f"&state={state}"
)
@app.route("/callback")
def callback():
# Surface any error Okta sent back instead of crashing on a missing code.
if "error" in request.args:
return (
f"<pre>error: {request.args.get('error')}\n"
f"description: {request.args.get('error_description')}</pre>",
400,
)
# Validate state to protect against CSRF.
expected_state = session.pop("oauth_state", None)
if not expected_state or request.args.get("state") != expected_state:
return "<pre>error: state mismatch</pre>", 400
code = request.args.get("code")
if not code:
return "<pre>error: no authorization code returned</pre>", 400
token_url = f"{OKTA_DOMAIN}/oauth2/v1/token"
resp = requests.post(token_url, data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": OIDC_CLIENT_ID,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(OIDC_CLIENT_ID, OIDC_KEY_ID, OIDC_SIGNING_KEY, token_url),
})
resp.raise_for_status()
id_token = resp.json()["id_token"]
id_jag = get_id_jag(id_token)
access_token = get_access_token(id_jag)
return f"""\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Okta secures AI</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f6f8;
color: #1d1d21;
margin: 0;
padding: 2.5rem;
}}
h1 {{
font-size: 1.6rem;
font-weight: 600;
color: #00297a;
margin: 0 0 1.5rem;
}}
table {{
border-collapse: collapse;
width: 100%;
max-width: 960px;
background: #fff;
border: 1px solid #d7dae0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}}
th, td {{
text-align: left;
padding: 0.85rem 1rem;
border-bottom: 1px solid #e6e8ec;
vertical-align: top;
}}
th {{
background: #00297a;
color: #fff;
font-weight: 600;
}}
tr:last-child td {{
border-bottom: none;
}}
td.token-name {{
font-weight: 600;
white-space: nowrap;
width: 1%;
}}
td.token-value {{
font-family: "SFMono-Regular", Menlo, Consolas, monospace;
font-size: 0.85rem;
word-break: break-all;
}}
</style>
</head>
<body>
<h1>Okta secures AI</h1>
<table>
<thead>
<tr><th>Token</th><th>Value</th></tr>
</thead>
<tbody>
<tr>
<td class="token-name">ID token</td>
<td class="token-value">{id_token}</td>
</tr>
<tr>
<td class="token-name">ID-JAG</td>
<td class="token-value">{id_jag}</td>
</tr>
<tr>
<td class="token-name">Access token</td>
<td class="token-value">{access_token}</td>
</tr>
</tbody>
</table>
</body>
</html>"""
if __name__ == "__main__":
app.run(port=5000)
Run the demo
Run the demo file:
uv run scripts/token-exchange-demo.py
Then open http://localhost:5000/ in your browser to start the sign-in flow. After you enter your Okta credentials, the full flow completes and the following tokens appear on the rendered page: ID token, ID-JAG token, and access token. See Complete the token exchange flow.
Note: This demo script plays both roles shown in the Token Exchange flow diagram. It signs the user in as the web app that issues the ID token, and then acts as the AI Agent, using the agent's private key to perform both steps of the token exchange. In a production integration, these are typically separate components.
Troubleshooting
The following errors come from the Okta token exchange scripts:
| Error | Root cause | Fix |
|---|---|---|
invalid_scope: openid not allowed | System scopes (openid/profile/email) are stripped in the ID-JAG flow | Use a custom scope such as xaa:read on the custom AS |
invalid_client: JWKSet not configured | The public key isn't registered on the AI Agent | Register the public JWK at Directory > AI Agents > (agent) > Credentials |
invalid_grant / invalid_token on step 1 | The user's id_token is expired or was issued by a different OIDC app than the one linked to the agent | Complete a fresh sign-in. Confirm that the aud claim equals the linked OIDC app's client ID |
invalid_client: kid is invalid | The kid in the signing code doesn't match the registered key | Copy the kid from the agent's Credentials into AGENT_KEY_ID (or OIDC_KEY_ID for the sign-in step) |
access_denied: no_matching_policy | The custom authorization server access policy is missing the JWT bearer grant | In the custom authorization server access policy rule, enable the JWT bearer grant |
Only service apps can use client_credentials | Wrong client type at the org authorization server | Only an Okta client can perform step 1. OIDC apps can't |
token_exchange_invalid_audience | Wrong flow path (for example, Web SSO instead of token exchange) | Use the AI Agent client for step 1, not the OIDC app |
Next steps
If you're integrating a supported AI agent platform, apply this flow using the platform-specific guide in Supported platforms.
Authenticating imported AI agents with delegated user identity is one part of the Okta for AI Agents framework. To define which resources and scopes an AI agent can reach, see Set up AI agent token exchange.