On this page
Secure a Salesforce Agentforce agent
This guide shows you how to build a FastAPI wrapper that authenticates users with Okta, performs Okta's two-step token exchange internally, and then calls a Salesforce Agentforce AI agent through the Agent API. Your app owns the full flow: it verifies who the user is, exchanges their identity for a scoped access token, obtains a separate Salesforce access token for the Agentforce Agent API, and passes the user's verified identity to the AI agent as context. Finally, you deploy this containerized wrapper to Azure Container Apps.
The Okta authentication is a two-step token exchange that's the same for any AI agent, regardless of the platform it runs on. This guide first introduces what the integration needs to do and provides sample code functions that implement the authentication. It then shows the Salesforce Agentforce-specific code and configuration that consumes it.
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 what an imported AI agent must do to authenticate as a signed-in user with Okta.
- Add a token exchange module to your AI agent.
- Authenticate to Salesforce with the OAuth 2.0 client credentials grant and call the Agentforce Agent API.
- Pass a signed-in user's verified Okta identity to an Agentforce AI agent session.
- Verify and test the end-to-end flow with a real Okta ID token.
What you need
- An Identity Engine org with the Okta for AI Agents feature enabled
- A Salesforce org with Agentforce enabled and an active Agentforce Service Agent (
ExternalCopilottype) that you can call - An Azure subscription with access to Azure Container Apps
- The Azure CLI (opens new window) (
az), installed and authenticated (az login) - Docker or access to the Azure Container Registry for building images
- Your AI agent imported and registered in Okta
- Python (opens new window) 3.10 or later
Overview
An AI agent has no inherent knowledge of an Okta user. To let it act for a specific user without sharing long-lived credentials, the AI agent exchanges the user's identity for a short-lived, narrowly scoped access token, and then uses that token to call protected resources.
The integration has two parts:
Okta authentication. The AI agent performs a two-step token exchange:
- Exchange the user's
id_tokenfor an Identity Assertion JWT authorization grant (ID-JAG) at the org authorization server. - Exchange the ID-JAG for a scoped
access_tokenat a custom authorization server.
This logic is identical for any AI agent. You add it once as a reusable module. See Add Okta authentication to your AI agent.
- Exchange the user's
Platform integration (Salesforce Agentforce-specific). Unlike the other platforms, Agentforce doesn't accept an Okta access token directly. Your wrapper authenticates to Salesforce separately with the OAuth 2.0 client credentials grant. Then it uses the resulting Salesforce token to start an Agentforce session and pass the user's verified Okta identity (name and email) as context in the message it sends to the AI agent. See Integrate the token exchange into your Agentforce agent.
Note: The Okta
access_tokenfrom steps 1 and 2 confirms the user's identity to your wrapper. It isn't forwarded to Salesforce. The Salesforce token from step 3 is a separate, service-level credential that authenticates your wrapper (not the user) to the Agentforce Agent API.
For the conceptual background on AI agent token exchange, see Set up AI agent token exchange.
Before you begin
The token exchange depends on Okta objects that you configure once per org. Confirm that the following are in place before you add any integration code. For detailed steps, see Set up imported AI agent token exchange.
A web app integration that signs users in and issues the
id_tokenthat your AI agent exchanges. Use the Authorization Code grant type and theopenid profile emailscopes. Theid_tokenmust have anaudclaim that matches the app's client ID.A custom authorization server. Use the built-in
defaultserver or create one.A custom scope on the custom authorization server, such as
xaa:read. Okta strips system scopes (openid,profile,email) during the ID-JAG exchange and can cause aninvalid_scopeerror, so you must request a custom scope instead.Your Agentforce AI agent imported and registered in Okta. This AI agent identity uses
private_key_jwtclient authentication, with its public key (JWK) registered. Link the app, set the custom authorization server, include your custom scope, and activate the AI agent. See Import your agent from Salesforce.Note: Okta doesn't retain the AI agent's private key. Generate the private key and store it in a secrets manager, because it's shown only once.
An access policy rule on the custom authorization server that enables the JWT Bearer grant type (
urn:ietf:params:oauth:grant-type:jwt-bearer), adds the AI agent as an allowed client, and includes the audience, the custom scope, and a user or group condition.
Salesforce import prerequisites
Ensure that your Salesforce tenant is configured for AI agent imports. See Configure Salesforce.com for AI agent imports (opens new window).
Ensure that the Agent Platform Builder permission set is configured, and your integration user has API Enabled turned on (either in another permission set or their user profile).
You've integrated the Salesforce AI Agent provider app in your Okta org.
Import your AI agent from Salesforce
Unlike manually registered AI agents, Okta can discover and import AI agents directly from a connected Salesforce org.
- In the Admin Console, go to Directory > AI agent Providers. Select the Salesforce app integration, and then click Import.
- After you run the import, you click View agents. You're directed to the AI agents page where you can register the imported AI agents.
- Note the client ID. You need it for
AGENT_CLIENT_ID. - In Client Authentication, select Public Key / Private Key. Generate an RSA key pair, and register the public JWK. Note the
kid. You need it forAGENT_KEY_ID. - In Resource connections, link the web app you created in Before you begin and the custom authorization server with the
xaa:readscope. - Activate the AI agent.
Set up your Salesforce org
Configure a separate, service-level integration in Salesforce so that your wrapper can authenticate to the Agentforce Agent API. This is independent of the Okta AI agent that you imported in the previous section.
Create an external client app
- In Salesforce Setup, go to App Manager, and then click External Client Apps.
- Enable OAuth settings.
- Note the Consumer Key and Consumer Secret. You need them for
SF_CLIENT_IDandSF_CLIENT_SECRET.
Configure OAuth settings and scopes
Set the grant type to Client Credentials.
Under Selected OAuth Scopes, select only the following scopes:
api(Manage user data through APIs)chatbot_api(Access chatbot services)sfap_api(Access the Salesforce API Platform)
Important: Adding more OAuth scopes than these three, combined with issuing JWT-based access tokens, can cause an
invalid_request: too many scopes requestederror. See Troubleshoot your integration.Enable Issue JSON Web Token (JWT)-based access tokens for named users.
Set IP Relaxation to Relax IP restrictions.
Enable the client credentials flow
- On the Connected App, go to Manage > Edit Policies.
- Enable Client Credentials Flow.
- Set Run As to a user with at least API access. The client credentials grant runs every Agent API call as this user, so that its permissions and login IP restrictions apply to every request. See Troubleshoot your integration.
Confirm your Agentforce AI agent
In Salesforce Setup, go to Agents, and then create or select an AI agent.
Confirm that the AI agent's type is
ExternalCopilot(an Agentforce Service Agent).Important: The Agent API only works with
ExternalCopilottype agents. It doesn't supportInternalCopilottype AI agents (Employee Agents) or AI agents of type Agentforce (Default).Publish the AI agent so that it has an active bot version.
Note the AI agent ID. It starts with
0Xx. You need it forSF_AGENT_ID.
Turn on Einstein
- In Salesforce Setup, go to Einstein Setup, and enable Einstein.
- Enable Einstein Generative AI, if it's available for your org.
Collect your configuration values
Your FastAPI app reads these values as environment variables. The token exchange module uses the first group. The second group is specific to Salesforce Agentforce.
Okta values (used by the token exchange):
| Environment variable | Description | Where to find it |
|---|---|---|
OKTA_DOMAIN | Okta org domain, for example example.okta.com (no https:// prefix) | Admin Console > Settings > Account |
OKTA_CUSTOM_AS_ID | Custom authorization server ID, for example default | Security > API |
OKTA_SCOPE | The custom scope that the agent requests | Custom AS > Scopes |
AGENT_CLIENT_ID | Client ID of the imported AI agent, for example wlp9k6... | Directory > AI Agents > (yourAgent) > Client registration |
AGENT_KEY_ID | kid of the public JWK registered on the AI agent | Directory > AI Agents > (yourAgent) > Client registration > Public/private key |
AGENT_PRIVATE_KEY_JWK | The AI agent's private JWK (single-line JSON) | Directory > AI Agents > (yourAgent) > Client registration > Public/private key. Output of Generate new key. Store the value in a secrets manager |
Salesforce Agentforce values (used by the platform integration):
| Environment variable | Description | Where to find it |
|---|---|---|
SF_MY_DOMAIN | Your Salesforce My Domain hostname, for example example.my.salesforce.com | Salesforce Setup > My Domain |
SF_CLIENT_ID | The external client app's consumer key | Salesforce Setup > App Manager > your connected app > Manage Consumer Details |
SF_CLIENT_SECRET | The External Client App's Consumer Secret | Salesforce Setup > App Manager > your connected app > Manage Consumer Details |
SF_AGENT_ID | The Agentforce AI agent to invoke (starts with 0Xx) | Salesforce Setup > Agents |
Add Okta authentication to your AI agent
The following example token_exchange.py module that you create here has no dependency on Salesforce or Agentforce.
Install the token exchange dependencies
The module needs only a JWT library and an HTTP client. Add these to your project's requirements.txt:
PyJWT[crypto]>=2.8.0
requests>=2.31.0 Create the token exchange module
Create a file named token_exchange.py. It reads the Okta values from the environment, signs the client assertion, and exposes two functions, get_id_jag and get_access_token, that your agent calls in order.
"""Okta token exchange for AI agents.
Turns a signed-in user's id_token into a scoped access_token:
id_token -> ID-JAG (org AS) -> access_token (custom AS)
Exposes get_id_jag() and get_access_token(). No platform dependencies.
"""
import json, os, time, uuid
import jwt
import requests
from jwt.algorithms import RSAAlgorithm
# --- Okta configuration (from environment) ---
OKTA_DOMAIN = os.environ["OKTA_DOMAIN"] # for example, example.okta.com
CUSTOM_AS_ID = os.environ.get("OKTA_CUSTOM_AS_ID", "default")
REQUESTED_SCOPE = os.environ.get("OKTA_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"])
ORG_TOKEN_URL = f"https://{OKTA_DOMAIN}/oauth2/v1/token"
CUSTOM_AS_TOKEN_URL = f"https://{OKTA_DOMAIN}/oauth2/{CUSTOM_AS_ID}/v1/token"
CUSTOM_AS_AUDIENCE = f"https://{OKTA_DOMAIN}/oauth2/{CUSTOM_AS_ID}"
def build_client_assertion(audience: str) -> str:
"""Sign a short-lived client assertion JWT for the given token endpoint."""
private_key = RSAAlgorithm.from_jwk(json.dumps(AGENT_PRIVATE_KEY_JWK))
now = int(time.time())
return jwt.encode(
{
"iss": AGENT_CLIENT_ID,
"sub": AGENT_CLIENT_ID,
"aud": audience, # must match the endpoint this assertion is sent to
"iat": now,
"exp": now + 300, # valid for 5 minutes
"jti": str(uuid.uuid4()),
},
private_key,
algorithm="RS256",
headers={"kid": AGENT_KEY_ID},
)
def get_id_jag(id_token: str) -> str:
"""Step 1: exchange the user's id_token for an ID-JAG at the org AS."""
r = 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(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": REQUESTED_SCOPE,
"audience": CUSTOM_AS_AUDIENCE,
}, timeout=10)
r.raise_for_status()
return r.json()["access_token"] # the ID-JAG
def get_access_token(id_jag: str) -> str:
"""Step 2: exchange the ID-JAG for a scoped access token at the custom AS."""
r = 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(CUSTOM_AS_TOKEN_URL),
"assertion": id_jag,
}, timeout=10)
r.raise_for_status()
return r.json()["access_token"] # scoped access token for the resource A few details that this module encodes:
-
The client assertion function is invoked twice.
build_client_assertionis called once per step, each time with theaudset to the token endpoint it targets: the org token URL for Step 1, and the custom authorization server token URL for Step 2. Thekidheader must match the public JWK registered on the agent. -
The
audienceparameter in Step 1 is the custom authorization server's issuer URL (https://{yourOktaDomain}/oauth2/{custom-as-id}), not its token endpoint. - Step 1 requires the Okta imported AI Agent client. An OIDC app client can't perform this exchange.
Note: For production workloads, cache the ID-JAG and access token in process until their
expclaim expires. This avoids a fresh two-step exchange on every user request.
Integrate the token exchange into your Agentforce AI agent
This section is specific to Salesforce Agentforce. Here you authenticate to Salesforce, call the Agentforce Agent API, and wire the result into your FastAPI app alongside the token exchange from the previous section.
Add the FastAPI and Salesforce dependencies
Add these to the same requirements.txt, alongside the token exchange dependencies:
fastapi>=0.110.0
uvicorn>=0.29.0
httpx>=0.27.0
python-dotenv>=1.0.0
Install the complete set of dependencies:
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
Project structure
okta-agentforce-agent/
├── main_agentforce.py # FastAPI entry point: token exchange + Agentforce call
├── token_exchange.py # Okta token exchange module
├── requirements.txt
├── Dockerfile
├── .env # Secrets (gitignored)
└── .env.example # Template
Create your environment file
Create a .env file with both the Okta values and the Salesforce values that you collected in Collect your configuration values.
Get a Salesforce access token
Authenticate to Salesforce with the OAuth 2.0 client credentials grant. This token authenticates your wrapper to the Agentforce Agent API. It's a separate, service-level credential, not the user's Okta access token.
import os
import httpx
SF_MY_DOMAIN = os.environ["SF_MY_DOMAIN"]
SF_CLIENT_ID = os.environ["SF_CLIENT_ID"]
SF_CLIENT_SECRET = os.environ["SF_CLIENT_SECRET"]
def get_salesforce_token() -> str:
resp = httpx.post(
f"https://{SF_MY_DOMAIN}/services/oauth2/token",
data={
"grant_type": "client_credentials",
"client_id": SF_CLIENT_ID,
"client_secret": SF_CLIENT_SECRET,
},
)
resp.raise_for_status()
return resp.json()["access_token"]
Note: The returned token has the
sfap_api,chatbot_api, andapiscopes. If you enabled more OAuth scopes than these three on the connected app, this call can fail. See Troubleshoot your integration.
Call the Agentforce Agent API
The Agentforce Agent API is synchronous: start a session, send a message, read the response, and end the session.
Important: The Agent API's base URL is always
https://api.salesforce.com, not your Salesforce instance URL. Your instance URL only appears in the session request'sinstanceConfig.endpointfield.
import uuid
SF_AGENT_ID = os.environ["SF_AGENT_ID"]
def ask_agentforce(prompt: str, user_claims: dict) -> str:
sf_token = get_salesforce_token()
sf_headers = {
"Authorization": f"Bearer {sf_token}",
"Content-Type": "application/json",
}
# Start session. instanceConfig.endpoint is required.
session_resp = httpx.post(
f"https://api.salesforce.com/einstein/ai-agent/v1/agents/{SF_AGENT_ID}/sessions",
headers=sf_headers,
json={
"externalSessionKey": str(uuid.uuid4()),
"bypassUser": True,
"instanceConfig": {"endpoint": f"https://{SF_MY_DOMAIN}"},
},
)
session_resp.raise_for_status()
session_id = session_resp.json()["sessionId"]
try:
# Send the prompt with the user's verified Okta identity as context.
message_resp = httpx.post(
f"https://api.salesforce.com/einstein/ai-agent/v1/sessions/{session_id}/messages",
headers=sf_headers,
json={
"message": {
"sequenceId": 1,
"type": "Text",
"text": f"The user is {user_claims.get('name')} ({user_claims.get('email')}). {prompt}",
},
},
)
message_resp.raise_for_status()
for msg in message_resp.json().get("messages", []):
if msg.get("type") == "Inform":
return msg.get("message", "")
return ""
finally:
# Always end the session, even if reading the response failed.
httpx.delete(
f"https://api.salesforce.com/einstein/ai-agent/v1/sessions/{session_id}",
headers=sf_headers,
)
Note: If the AI agent has nothing configured for the topic of your prompt, it responds with something like "Sorry, I can't assist with that." Configure the agent's topics in Salesforce Setup > Agents, or ask it something within its configured scope.
Wire it into the FastAPI entry point
In your app's entry point, call the two token exchange functions in order, decode the user's identity claims from the id_token, and then call Agentforce. The following example main_agentforce.py imports the reusable token exchange module and adds only the Salesforce-specific wiring:
import jwt
from fastapi import FastAPI
from pydantic import BaseModel
from token_exchange import get_id_jag, get_access_token
# get_salesforce_token, ask_agentforce from the previous steps
app = FastAPI()
class InvokeRequest(BaseModel):
id_token: str
prompt: str
@app.get("/health")
def health() -> dict:
return {"ok": True}
@app.post("/invoke")
def invoke(request: InvokeRequest) -> dict:
# Okta authentication
id_jag = get_id_jag(request.id_token)
access_token = get_access_token(id_jag)
# The id_token was already verified by the org authorization server in
# Step 1. Decoding it here only reads display claims to pass to Agentforce.
# It isn't used to make an authorization decision.
user_claims = jwt.decode(request.id_token, options={"verify_signature": False})
# Platform integration (Salesforce Agentforce)
answer = ask_agentforce(request.prompt, user_claims)
return {
"ok": True,
"answer": answer,
"user": user_claims.get("email"),
"access_token_prefix": access_token[:10],
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Deploy to Azure Container Apps
Package your FastAPI app into a container image, grant Azure Container Apps access to pull it, and deploy it with the environment variables you collected in Collect your configuration values.
Build the image in ACR
cd /path/to/okta-agentforce-agent
az acr build --registry <registry-name> --image okta-agent-agentforce:latest .
Grant the container app access to ACR
az containerapp registry set \
--name <app-name> \
--resource-group <resource-group> \
--server <registry-name>.azurecr.io \
--username <registry-name> \
--password <acr-password>
Deploy with environment variables
az containerapp update \
--name <app-name> \
--resource-group <resource-group> \
--image <registry-name>.azurecr.io/okta-agent-agentforce:latest \
--set-env-vars \
OKTA_DOMAIN="https://example.okta.com" \
OKTA_CUSTOM="default" \
OKTA_SCOPE="xaa:read" \
AGENT_CLIENT_ID="<wlp...>" \
AGENT_KEY_ID="<kid>" \
AGENT_PRIVATE_KEY_JWK='<private key json>' \
SF_MY_DOMAIN="example.my.salesforce.com" \
SF_AGENT_ID="0Xx..." \
SF_CLIENT_ID="<consumer-key>" \
SF_CLIENT_SECRET="<consumer-secret>"
Verify the target port
If the container app returns the default Azure page, the target port defaults to 80. Update it:
az containerapp ingress update \
--name <app-name> \
--resource-group <resource-group> \
--target-port 8000
Verify the configuration
After you add the code, verify the Okta-side configuration:
- Go to Directory > AI Agents and confirm that the agent appears with Status: Active and the expected owners, connections, and user app.
- (Optional) Go to Identity Governance > Access Certifications to confirm that the agent's user sign-on app is visible for future certification campaigns.
Obtain a test ID token
To exercise the flow, you need an ID token from the OIDC app that's linked to the AI agent. Complete an OIDC sign-in against that app to obtain one. For an example Authorization Code sign-in helper that authenticates with private_key_jwt, see Create an app to obtain a test ID token.
Note: Add the helper's callback URL (for example,
http://localhost:5000/callback) to the linked OIDC app's Sign-in redirect URIs before you run it, and remove it after verification is complete.
Run an end-to-end invocation
Call the /invoke endpoint on your deployed container app, passing the test ID token to verify the end-to-end integration:
curl -s -X POST "https://<your-container-app-url>/invoke" \
--header "Content-Type: application/json" \
--data "{\"id_token\": \"$ID_TOKEN\", \"prompt\": \"Hello, what can you help me with?\"}"
A successful response appears as follows and confirms the full round trip:
{
"ok": true,
"answer": "Hi there! Can you let me know what you need help with? I'll do my best to assist!",
"user": "jessie.smith@example.com",
"access_token_prefix": "eyJraWQiOiI1ZXpPR0dSZzFf..."
}
Troubleshoot your integration
The following errors are specific to the Salesforce Agentforce integration:
| Error | Root cause | Fix |
|---|---|---|
invalid_grant: no client credentials user enabled | The connected app has no Run As users configured for the client credentials flow | Go to the connected app's Manage > Edit Policies > Client Credentials Flow, and assign a Run As user |
invalid_grant: ip restricted | The Run As user's profile has login IP ranges that block the caller's IP | Add the caller's IP to the profile's login IP ranges, remove the login IP ranges, or use a Run As user on a profile without IP restrictions |
invalid_request: too many scopes requested | The connected app has Issue JWT-based access tokens enabled along with too many OAuth scopes | Reduce the connected app's OAuth scopes to only api, chatbot_api, and sfap_api |
Agent API returns URL No Longer Exists (HTML 404 page) | The request used the Salesforce instance URL (*.my.salesforce.com) as the Agent API base | Use https://api.salesforce.com/einstein/ai-agent/v1/... as the base URL instead |
BadRequestException: Empty force-config endpoint | The session request body is missing instanceConfig.endpoint | Include {"instanceConfig": {"endpoint": "https://your-instance.my.salesforce.com"}} in the session POST body |
Agent API returns 404 for the AI agent | The AI agent is InternalCopilot type (an employee AI agent) | Use an ExternalCopilot type AI agent (an Agentforce service AI gent). Employee AI agents aren't supported |
| AI agent responds "Sorry, I can't assist with that" | The AI agent's topics don't cover the question asked | Configure the AI agent's topics in Salesforce Setup > Agents, or ask it something within its configured scope |
The following errors come from the Okta token exchange and are covered in Set up imported AI agent token exchange: Troubleshooting:
invalid_scope: openid not allowedinvalid_client: JWKSet not configuredinvalid_client: kid is invalidaccess_denied: no_matching_policyOnly service apps can use client_credentials
Next steps
Your AI agent can now authenticate as a user and call Okta-protected resources on their behalf. To define which resources and scopes the AI agent is permitted to reach, see Set up AI agent token exchange and the Okta for AI Agents documentation on governing access to AI agents.