cd /news/developer-tools/okta-saml-and-keycloak-for-id-jag-cr… · home topics developer-tools article
[ARTICLE · art-76176] src=blog.christianposta.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Okta SAML and Keycloak for ID-JAG Cross App Access

Okta SAML and Keycloak can interoperate via the Identity Assertion JWT Authorization Grant (ID-JAG) draft to enable cross-app access, according to a demo by Christian Posta. The flow converts enterprise SAML assertions into scoped OAuth tokens for external APIs without user-facing consent screens. The demo uses Okta as the enterprise IdP and Keycloak as the resource authorization server, following the ID-JAG SAML profile.

read9 min views19 publishedJul 13, 2026

A working Okta SAML/SSO → ID-JAG → Keycloak access-token path for Cross-App Access

The Identity Assertion JWT Authorization Grant (ID-JAG) draft, which is the core of Cross-App Access (XAA), standardizes how enterprises broker access to APIs and MCP resources across identity boundaries (think “calling from enterprise apps to SaaS APIs and MCP servers”).

Enterprises already use their Enterprise IdP for SSO, but SSO only identifies the user. It says nothing about that user’s service-specific permissions on GitHub, Asana, Figma, and so on. You might say “we already use SSO on those apps, why is that not enough” … and you do, for login (usually to some UI). Those apps still need scoped OAuth access tokens for API access, not just an identity assertion. ID-JAG is the bridge: convert enterprise user identity into those scoped tokens, under IdP policy.

The flow looks like this:

  • The user signs into enterprise app/agent via SSO (OIDC ID Token or SAML assertion), so the app knows who they are.
  • When the app needs to call an external resource (SaaS API, MCP server, etc.), it exchanges that identity assertion at the enterprise IdP for an intermediate ID-JAG viaOAuth 2.0 Token Exchange. This is where the enterprise decides whether to allow the access, what scopes to grant, and how to manage the authorization lifecycle. - The app presents the ID-JAG to the resource authorization server as a JWT authorization grantand receives a provider-scoped access token. - The app uses that access token to call the resource (MCP tools, APIs, etc.).

The user never sees an OAuth consent screen at the SaaS app. Access is pre-decided by the enterprise IdP and controlled by admin policy.

I recently dug into a working example with Okta as the enterprise IdP and Keycloak as the resource/service IdP. Since ID-JAG is an open OAuth draft / spec, the most important part of the story is interoperability. And to take it one step further: although the happy path in the draft primarily uses OIDC for user identity assertions, enterprises still standardize around SAML. So let’s build an Okta SAML-based requesting app, follow the ID-JAG SAML profile in the draft, and redeem that grant at a different identity domain: Keycloak standing in for the resource authorization server.

Everything here is reproducible from christian-posta/okta-saml-idjag.

TL;DR here is a demo video of this blog:

Actors in this demo #

Worth naming explicitly up front, because “app” and “agent” blur quickly:

Role In this demo What it does
User Okta test user (mcpuser@… ) Authenticates via SAML SSO
Requesting app Local demo app (localhost:4141 ) Holds the SAML session, drives both token exchanges, calls the resource
Enterprise IdP Okta (Integrator / XAA early access) Issues SAML assertions and ID-JAGs under admin policy
AI Agent (OAuth client) Okta “AI Agent” registration The private_key_jwt client that actually calls Okta’s token endpoint
Resource AS Keycloak realm idjag-resource Trusts Okta’s ID-JAG, mints its own access token
Resource client idjag-demo-client Client that presents the ID-JAG at Keycloak (client_id claim must match)

The requesting app and the Okta AI Agent are related but not the same object. The user signs into the SAML app; the AI Agent is the OAuth client allowed to exchange that identity for an ID-JAG.

The end-to-end path (including the SAML hop) #

OIDC-shaped XAA is often drawn as one token exchange at the IdP, then a JWT bearer grant at the resource. With SAML, the draft adds a pre-step: turn the SAML assertion into a refresh token

first, then exchange that refresh token

for the ID-JAG.

sequenceDiagram
    actor User
    participant App as Requesting app
    participant Okta as Okta (enterprise IdP)
    participant KC as Keycloak (resource AS)
    participant API as Resource API

    User->>App: Login
    App->>Okta: SAML AuthnRequest
    Okta-->>App: SAML Assertion

    Note over App,Okta: Leg 1 — SAML → refresh token
    App->>Okta: Token Exchange<br/>subject_token=SAML (saml2)<br/>requested=refresh_token
    Okta-->>App: refresh_token

    Note over App,Okta: Leg 2 — refresh → ID-JAG
    App->>Okta: Token Exchange<br/>subject_token=refresh_token<br/>requested=id-jag<br/>aud=Keycloak issuer
    Okta-->>App: ID-JAG (aud=Keycloak)

    Note over App,KC: Cross trust domain
    App->>KC: JWT Bearer Grant<br/>assertion=ID-JAG
    KC-->>App: access_token (Keycloak-issued)

    App->>API: Bearer access_token
    API-->>App: resource response

Why the extra leg? A SAML assertion proves the IdP authenticated the user, but it is not a durable OAuth subject the way an ID token or refresh token is. The SAML interoperability section profiles assertion → refresh, then refresh → ID-JAG. With OIDC you can often go ID Token → ID-JAG in one exchange; with SAML you pay the two-leg tax. That is intentional in the draft, not an Okta quirk.

Setting up Okta for SAML and XAA #

Okta has early access to XAA in its enterprise product and in free Integrator accounts. We build a demo app that logs a user in with SAML:

The three Okta objects that have to link

Okta needs three objects plus I use a local signing key (instead of client secrets). Here’s a flow diagram of the pieces:

Okta object Role Key fields
SAML app App the user signs into. Its assertion is the subject of leg 1. ACS, Audience, NameID=email
AI Agent OAuth client that authenticates the token exchange via private_key_jwt . Not a normal OIDC app. Credentials (public key), Delegation → SAML app, Resource connection → resource app
Resource app (OIDC) Defines the resource being accessed; Resource Server issuer URL becomes ID-JAG aud . Enable XAA + Issuer URL = Keycloak realm issuer
Local RSA keypair Signs the agent’s client_assertion . Public half registered on the agent. e.g. kid=okta-xaa-1

Full click-path: docs/okta-setup.md. One constraint that matters early: the resource issuer must be a real, reachable, distinct authorization server (not Okta’s own org AS). Okta validates RFC 8414 metadata when you save the Resource app, so Keycloak (exposed publicly, e.g. via ngrok) needs to be up first.

SAML app and login

Create the Okta SAML app, wire ACS / audience, assign users/groups:

Once that is wired into the demo app, login should land on a dashboard:

Leg 1 and leg 2 at Okta’s token endpoint

From the SAML interoperability section, exchange the SAML assertion for a refresh token first:

1
2
3
4
5
6
7
8
9
10
11
POST /oauth2/token HTTP/1.1
Host: acme.idp.example
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:refresh_token
&scope=openid+offline_access+email
&subject_token=PHNhbWxwOkFzc2VydGlvbiB4bWxuczp...c2FtbDppc3N1ZXI+PC9zYW1sOkFzc2VydGlvbj4=
&subject_token_type=urn:ietf:params:oauth:token-type:saml2
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...
1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "issued_token_type": "urn:ietf:params:oauth:token-type:refresh_token",
  "access_token": "vF9dft4qmTcXkZ26zL8b6u",
  "token_type": "N_A",
  "scope": "openid offline_access email",
  "expires_in": 1209600
}

Then follow the refresh → ID-JAG exchange:

1
2
3
4
5
6
7
8
9
10
11
12
POST /oauth2/token HTTP/1.1
Host: acme.idp.example
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&requested_token_type=urn:ietf:params:oauth:token-type:id-jag
&audience=https://keycloak.example/realms/idjag-resource
&scope=demo:read+demo:write
&subject_token=tGzv3JOkF0XG5Qx2TlKWIA
&subject_token_type=urn:ietf:params:oauth:token-type:refresh_token
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6IjIyIn0...

A few Okta-specific notes on that second call:

  • Authenticate as the AI Agent withprivate_key_jwt

(iss

/sub

= agentclient_id

,aud

= Okta token endpoint). - Set audience

to the Keycloak realm issuer (the Resource app’s XAA issuer URL). - Do not send aresource

parameter — Okta rejects it here ('resource' is invalid or not supported

), even though some draft examples include it.

In the demo UI, Get ID-JAG runs both legs:

What the ID-JAG actually carries

An annotated shape of what Okta minted in this run (values shortened):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// header
{
  "alg": "RS256",
  "kid": "…",
  "typ": "oauth-id-jag+jwt"   // marks this as an ID-JAG, not a normal access token
}

// claims
{
  "iss": "https://integrator-….okta.com",                 // enterprise IdP
  "aud": "https://…/realms/idjag-resource",               // Keycloak realm issuer
  "client_id": "idjag-demo-client",                       // client that will redeem at Keycloak
  "sub": "00u…",                                          // Okta user id
  "sub_profile": "user",                                  // entity profile, "user" (or "ai_agent")
  "email": "mcpuser@example.org",                         // from SAML NameID / profile
  "act": { "sub": "wlp…", "sub_profile": "ai_agent" },    // the AI Agent that did the exchange
  "scope": "demo:read demo:write",                        // scopes for the *resource* domain
  "exp": …                                                // short-lived (~300s on Okta)
}

Read that carefully:

aud

isKeycloak, not Okta — the grant is audience-restricted to the resource AS.client_id

is the client that will present the grant at Keycloak (idjag-demo-client

), not the Okta AI Agent client id.scope

values are resource-domain scopes, decided by Okta policy / resource connection.act

attributes the exchange to the AI Agent acting on the user’s behalf.sub

Okta user idsub_profile

from the newEntity Profiles draft- This is a grant, not an access token. It cannot call the API directly; it only exists to be redeemed.

Exchange the ID-JAG across an identity boundary #

For the resource side, we run Keycloak with the JWT Authorization Grant feature (GA in Keycloak 26.6). That is the RFC 7523 grant that consumes an ID-JAG. Keycloak is also working on the enterprise-IdP side (issuing ID-JAGs); here it only plays resource AS.

Setup details: docs/keycloak-setup.md. The three pieces that matter:

Identity Provider of typejwt-authorization-grant

, trusting Okta’siss

  • org JWKS.Confidential clientidjag-demo-client

with JWT authorization grant enabled, allowed IdPs including that Okta IdP.Pre-provisioned user linked by federated identity(okta-idjag, Okta sub)

— this grant is non-interactive, so JIT / first-broker-login does not run.

Back in the demo, Exchange at Keycloak:

The HTTP shape is RFC 7523 jwt-bearer (not another token exchange):

1
2
3
4
5
6
7
POST /realms/idjag-resource/protocol/openid-connect/token HTTP/1.1
Host: keycloak.example
Authorization: Basic base64(idjag-demo-client:…)
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QiLC…

Keycloak then:

  • Matches iss

→ the Okta jwt-authorization-grant IdP - Verifies the signature against Okta’s JWKS

  • Checks aud

equals this realm’s issuer - Checks ID-JAG client_id

equals the authenticating client - Resolves the user by (okta-idjag, sub)

  • Issues its own access token

That last hop is the point of XAA: Okta brokered cross-domain API access using the same SSO trust relationship, without a consent screen at Keycloak / the resource.

Gotchas that ate the most time

  • The token-exchange client must be the AI Agent... we useprivate_key_jwt

to identify the client (instead of client secret). If you try to use a normal OIDC Web/API, the Okta flow tends to land in On-Behalf-Of and fail withactor_token missing

. - Okta rejects a resource

parameter on this token exchange even when draft examples show one. audience

must be adistinct, reachable resource AS issuer (RFC 8414).- SAML subject_token

isstandard base64, not base64url. - Keycloak must advertise an https

issuer (e.g. setKC_HOSTNAME

to the ngrok URL) or Okta will not accept it as the XAA resource. - Pre-provision the Keycloak user with federated identity keyed on the Okta sub

, or redemption fails withUser not found

. - ID-JAG lifetime on Okta is short (~300s) ... redeem promptly.

Recreate This Demo #

This path works end-to-end today:

Okta (SAML enterprise IdP + XAA) → ID-JAG → Keycloak (resource AS JWT authorization grant) → access token → API call

That is the interoperability story that matters for ID-JAG: different vendors, different trust domains, SAML on the enterprise side, open-source AS on the resource side.

If you want to reproduce: github.com/christian-posta/okta-saml-idjag. Follow along / connect on LinkedIn if you are working through XAA / ID-JAG in your own stack.

CC BY 4.0by the author.

── more in #developer-tools 4 stories · sorted by recency
── more on @okta 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/okta-saml-and-keyclo…] indexed:0 read:9min 2026-07-13 ·