Identity Platform

Integrate PingFederate SSO with SAML and OAuth Token Exchange for Downstream APIs

A global manufacturer is consolidating four business units onto one engineering platform, and each unit shows up with a different identity story: corporate IT lives in Microsoft Entra ID, an acquired SaaS division still runs Okta, the legacy ERP only speaks SAML 2.0, and the new internal API mesh wants nothing but short-lived, audience-scoped OAuth bearer tokens. The mandate from the CISO is blunt — “one front door, one session, and no long-lived passwords in front of the APIs.” Re-platforming every app onto a single IdP is a two-year project nobody funded. The pragmatic answer is a federation broker: PingFederate sits in the middle, accepts SAML browser SSO from whichever IdP owns the user, and then performs RFC 8693 OAuth 2.0 token exchange to mint a fresh, narrowly-scoped JWT for each downstream API call. This guide builds that broker end to end — SAML in, token exchange out — with real config, real CLI, and a rollback path.

Prerequisites

Target topology

Integrate PingFederate SSO with SAML and OAuth Token Exchange for Downstream APIs — topology

The flow has two distinct legs that share one session, and keeping them separate in your head is the whole game. Leg one is browser SSO over SAML: the user hits the platform portal, gets redirected through Akamai to a PingFederate SP connection that consumes a SAML assertion from Entra ID or Okta and establishes a PingFederate session. Leg two is machine-to-machine token exchange: the portal’s backend-for-frontend (BFF) takes the resulting token and calls PingFederate’s /as/token.oauth2 endpoint with grant_type=urn:ietf:params:oauth:grant-type:token-exchange, handing in the inbound token (a SAML assertion or an Entra/Okta JWT) as the subject_token and asking for a fresh access token whose aud is exactly the downstream API. PingFederate validates the subject, applies your policy, and signs a new JWT. The downstream microservice never sees the SAML world at all — it validates one thing, the broker’s JWKS.

PingFederate plays two roles at once here: a SAML Service Provider (it receives assertions from the upstream IdPs) and an OAuth Authorization Server (it issues tokens to the API mesh). That dual role is exactly why it can broker between the two protocols.

1. Stand up the cluster and seed keys from Vault

Install identically on every node, then differentiate by role in run.properties. The admin console node coordinates; the engine nodes serve runtime traffic.

# On every node — unpack and set the operational mode
cd /opt/pingfederate/bin
# Admin console node:
sed -i 's/^pf.operational.mode=.*/pf.operational.mode=CLUSTERED_CONSOLE/' run.properties
# Each runtime engine node:
sed -i 's/^pf.operational.mode=.*/pf.operational.mode=CLUSTERED_ENGINE/' run.properties

# Point engines at the console for config replication (run.properties)
#   pf.cluster.node.index      unique per node
#   pf.cluster.bind.address    this node's IP
#   pf.cluster.tcp.discovery.initial.hosts  console:7600

Do not let the SAML signing key or the token-exchange signing key live on disk in the clear. Pull them from HashiCorp Vault at boot and write them into the PingFederate keystore, so the secret is leased to the host and rotated centrally rather than committed anywhere:

# Authenticate the node to Vault (AppRole bound to this host's identity)
export VAULT_ADDR="https://vault.internal:8200"
ROLE_ID=$(cat /etc/pf/role_id)
SECRET_ID=$(vault write -field=secret_id auth/approle/role/pingfederate/secret-id)
export VAULT_TOKEN=$(vault write -field=token auth/approle/login \
  role_id="$ROLE_ID" secret_id="$SECRET_ID")

# Fetch the SAML signing keypair (PKCS#12) and import it into PingFederate's store
vault kv get -field=p12 secret/pingfederate/saml-signing | base64 -d > /tmp/saml-signing.p12
keytool -importkeystore \
  -srckeystore /tmp/saml-signing.p12 -srcstoretype PKCS12 \
  -destkeystore /opt/pingfederate/server/default/data/keystore.jks \
  -destalias saml-signing -deststorepass "$(vault kv get -field=kspass secret/pingfederate/keystore)"
shred -u /tmp/saml-signing.p12

Start the console first, then the engines, and confirm they join:

/opt/pingfederate/bin/run.sh &           # console node
# then on each engine:
/opt/pingfederate/bin/run.sh &
# Verify cluster membership from the admin API
curl -sk -u administrator:"$PF_ADMIN_PW" \
  https://pf-console:9999/pf-admin-api/v1/cluster/status | jq '.nodes[].mode'

2. Configure the SAML SP connection to Entra ID (and Okta)

This is leg one — PingFederate as a SAML Service Provider consuming assertions. Export your IdP metadata first; it carries the entity ID, the SSO endpoint, and the signing certificate so you do not transcribe them by hand.

# Entra ID federation metadata (per Enterprise Application)
curl -s "https://login.microsoftonline.com/<TENANT_ID>/federationmetadata/2007-06/federationmetadata.xml?appid=<APP_ID>" \
  -o entra-idp-metadata.xml

# Okta SAML app metadata (App > Sign On > "Identity Provider metadata")
curl -s "https://<your-org>.okta.com/app/<app_id>/sso/saml/metadata" -o okta-idp-metadata.xml

In the PingFederate admin console, create an SP Connection (you are the SP) for each IdP under Authentication > SP Connections > Create Connection:

On the Entra side, register PingFederate’s SP metadata so the trust is mutual. PingFederate publishes it at https://sso.kloudvin.com/pf/federation_metadata.ping?PartnerSpId=<your-sp-id>. The fast path with the Microsoft Graph CLI:

# Set the Entra Enterprise App's reply URL (ACS) and identifier (SP entity ID)
az ad app update --id <APP_ID> \
  --web-redirect-uris "https://sso.kloudvin.com/sp/ACS.saml2" \
  --identifier-uris "https://sso.kloudvin.com"

Repeat the SP-connection creation for the Okta metadata. You now have two upstream SAML IdPs feeding one broker. Validate the raw SAML round-trip in the browser via PingFederate’s SP-initiated SSO test URL before going further:

https://sso.kloudvin.com/sp/startSSO.ping?PartnerIdpId=https://sts.windows.net/<tenant>/

A successful login lands on the configured target and writes a PingFederate session — that is leg one done.

3. Configure the OAuth Authorization Server and clients

Now flip PingFederate into its OAuth Authorization Server role. Enable the AS under System > OAuth Settings > Authorization Server Settings, set the issuer to https://sso.kloudvin.com, and turn on the token exchange grant type at the server level — it is off by default.

Define the access token manager that will mint the downstream JWTs. Use a JWT token manager (not internally-referenced opaque tokens) so resource servers can validate offline against JWKS:

# Create a JWT Access Token Manager via the admin API
curl -sk -u administrator:"$PF_ADMIN_PW" -X POST \
  https://pf-console:9999/pf-admin-api/v1/oauth/accessTokenManagers \
  -H 'Content-Type: application/json' -H 'X-XSRF-Header: PingFederate' -d '{
    "id": "jwt-downstream",
    "name": "Downstream API JWT",
    "pluginDescriptorRef": { "id": "com.pingidentity.pf.access.token.management.plugins.JwtBearerAccessTokenManagementPlugin" },
    "configuration": {
      "fields": [
        { "name": "Token Lifetime", "value": "300" },
        { "name": "JWS Algorithm", "value": "RS256" },
        { "name": "Active Signing Certificate Key ID", "value": "tokenexchange-signing" }
      ]
    },
    "accessControlSettings": { "restrictClients": true },
    "attributeContract": {
      "extendedAttributes": [
        { "name": "sub" }, { "name": "scope" }, { "name": "groups" }, { "name": "act" }
      ]
    }
  }'

Register the OAuth client that the portal’s BFF uses to perform the exchange. It authenticates with mutual TLS (client cert), and it is the only principal allowed to request token exchange:

curl -sk -u administrator:"$PF_ADMIN_PW" -X POST \
  https://pf-console:9999/pf-admin-api/v1/oauth/clients \
  -H 'Content-Type: application/json' -H 'X-XSRF-Header: PingFederate' -d '{
    "clientId": "platform-bff",
    "name": "Platform BFF (token exchange)",
    "grantTypes": ["urn:ietf:params:oauth:grant-type:token-exchange"],
    "clientAuthnType": "CERTIFICATE",
    "restrictScopes": true,
    "restrictedScopes": ["erp.read", "erp.write", "orders.read"],
    "defaultAccessTokenManagerRef": { "id": "jwt-downstream" }
  }'

Pull the client’s mutual-TLS keypair from HashiCorp Vault the same way you did the signing keys in step 1 — never store it next to the application code.

4. Build the token-exchange processor and policy

This is the heart of leg two. PingFederate needs to (a) recognize the inbound subject_token, (b) validate it, and © decide what scopes and audience the new token gets. Two pieces wire this up: a Token Exchange Processor (validates the subject) and a Token Generator mapping (issues the result).

Create a processor that accepts a SAML 2.0 assertion as the subject token. Under OAuth Settings > Token Exchange > Processor Policies, add a SAML 2.0 Token Processor and bind it to the subject-token type urn:ietf:params:oauth:token-type:saml2. For inbound JWTs from Entra/Okta (when the BFF already holds an OIDC token), add a JWT Token Processor validating against the upstream IdP’s JWKS:

# JWT processor that trusts Entra-issued subject tokens
curl -sk -u administrator:"$PF_ADMIN_PW" -X POST \
  https://pf-console:9999/pf-admin-api/v1/oauth/tokenProcessors \
  -H 'Content-Type: application/json' -H 'X-XSRF-Header: PingFederate' -d '{
    "id": "entra-jwt-subject",
    "name": "Entra Subject JWT",
    "pluginDescriptorRef": { "id": "com.pingidentity.pf.oauth.tokenprocessor.JwtTokenProcessor" },
    "configuration": { "fields": [
      { "name": "JWKS Endpoint", "value": "https://login.microsoftonline.com/<TENANT_ID>/discovery/v2.0/keys" },
      { "name": "Issuer", "value": "https://login.microsoftonline.com/<TENANT_ID>/v2.0" },
      { "name": "Expected Audience", "value": "api://platform-bff" }
    ] }
  }'

Now the policy that maps the validated subject into the new token’s claims. The critical decisions: the audience of the issued token must equal the downstream API’s identifier, and the scopes must be downscoped from whatever the user is entitled to. Encode least privilege here — the BFF asks for erp.read, and policy refuses to upgrade it to erp.write. Add an OAuth Token Exchange Mapping that pulls groups from the processor and emits the act (actor) claim so the downstream service can audit that platform-bff acted on the user’s behalf:

# Issuance criteria (Token Exchange Mapping > Issuance Criteria)
#   ${groups} contains "ERP-Users"      -> allow erp.read
#   ${groups} contains "ERP-Editors"    -> allow erp.write
# Contract fulfillment (claims on the NEW token):
#   sub   = ${subject.SAML_SUBJECT}
#   aud   = https://erp-api.kloudvin.com         (audience-bound!)
#   scope = ${requested_scope ∩ entitled_scope}  (downscoped)
#   act   = { "sub": "platform-bff" }            (delegation audit)

5. Perform and wire the token exchange call

With the AS, client, processor, and policy in place, the BFF can exchange tokens. Here is the literal RFC 8693 request — grant_type is the token-exchange URI, subject_token is the inbound credential, and audience pins the result to one API:

# The BFF authenticates with its client cert (mutual TLS) and exchanges
# an Entra subject token for a downstream-scoped access token.
SUBJECT_TOKEN="<inbound Entra JWT or base64 SAML assertion>"

curl -s --cert /run/secrets/bff-client.pem --key /run/secrets/bff-client.key \
  -X POST "https://sso.kloudvin.com/as/token.oauth2" \
  -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:jwt" \
  -d "audience=https://erp-api.kloudvin.com" \
  -d "scope=erp.read" \
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" | jq .

A correct response returns a fresh access_token (a JWT whose aud is https://erp-api.kloudvin.com, lifetime 300s, carrying the act claim), plus issued_token_type echoing the requested type. The BFF attaches that token as Authorization: Bearer … on the downstream call and never forwards the original SAML/Entra credential.

The downstream microservice validates offline against the broker’s JWKS — no call back to PingFederate per request. A Spring Boot resource server needs only:

# application.yml on the ERP API
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://sso.kloudvin.com
          # audiences enforced in a custom validator:
          audiences: https://erp-api.kloudvin.com

6. Automate with Terraform and a CI gate

Keep every PingFederate object — SP connections, AS settings, clients, processors, mappings — in Terraform so the broker is reproducible and reviewable, not click-built. The pingfederate provider talks to the same admin API:

provider "pingfederate" {
  username = "administrator"
  password = var.pf_admin_password          # sourced from Vault, never in state plaintext
  base_url = "https://pf-console:9999"
}

resource "pingfederate_oauth_client" "bff" {
  client_id          = "platform-bff"
  name               = "Platform BFF (token exchange)"
  grant_types        = ["urn:ietf:params:oauth:grant-type:token-exchange"]
  client_auth_type   = "CERTIFICATE"
  restrict_scopes    = true
  restricted_scopes  = ["erp.read", "erp.write", "orders.read"]
  default_access_token_manager_ref { id = "jwt-downstream" }
}

Promote through environments with GitHub Actions, authenticating to the admin API over a short-lived credential and running a smoke test (step 7) as a required gate before the change reaches production:

# .github/workflows/pingfederate.yml (excerpt)
jobs:
  apply:
    runs-on: self-hosted          # runner inside the VPC, reaches pf-console:9999
    steps:
      - uses: actions/checkout@v4
      - run: terraform init && terraform plan -out tf.plan
      - run: terraform apply -auto-approve tf.plan
      - run: ./scripts/smoke-token-exchange.sh   # fails the job if exchange breaks

Validation

Verify each leg independently, then end to end.

# 1. AS is healthy and advertises token exchange + the JWKS the API will use
curl -s https://sso.kloudvin.com/.well-known/openid-configuration | \
  jq '{issuer, grant_types_supported, jwks_uri, token_endpoint}'
curl -s https://sso.kloudvin.com/pf/JWKS | jq '.keys[].kid'

# 2. SAML leg: SP-initiated SSO returns a 200 and sets a PingFederate session cookie
curl -sI "https://sso.kloudvin.com/sp/startSSO.ping?PartnerIdpId=https://sts.windows.net/<tenant>/"

# 3. Token-exchange leg: the response token has the right aud, scope, and act claim
RESP=$(curl -s --cert bff-client.pem --key bff-client.key \
  -X POST https://sso.kloudvin.com/as/token.oauth2 \
  -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:jwt \
  -d audience=https://erp-api.kloudvin.com -d scope=erp.read)
echo "$RESP" | jq -r .access_token | cut -d. -f2 | base64 -d 2>/dev/null | \
  jq '{aud, scope, act, exp}'   # expect aud=erp-api, scope="erp.read", act.sub="platform-bff"

# 4. End to end: the downstream API accepts the new token and rejects the old one
curl -s -H "Authorization: Bearer $(echo "$RESP" | jq -r .access_token)" \
  https://erp-api.kloudvin.com/api/v1/orders | jq '.[0]'

Drive the live login through a synthetic check in Dynatrace (or Datadog) so SSO availability and the p95 of the /as/token.oauth2 endpoint are dashboards, not surprises — the token endpoint is now on the critical path of every API call. Pipe PingFederate’s audit log (one line per issuance) to your SIEM and alert on a spike in failed_token_exchange.

Rollback / teardown

Because everything is in Terraform, rollback is a revert — but do it in the safe order so you never strand a live session. Tear down the consumers before the issuer.

# 1. Stop new exchanges: disable the client (keeps existing 5-min tokens valid till expiry)
curl -sk -u administrator:"$PF_ADMIN_PW" -X PATCH \
  https://pf-console:9999/pf-admin-api/v1/oauth/clients/platform-bff \
  -H 'Content-Type: application/json' -H 'X-XSRF-Header: PingFederate' \
  -d '{"enabled": false}'

# 2. Revert the IaC change set (returns AS settings, processors, mappings to last good)
terraform plan -destroy -target=pingfederate_oauth_client.bff
terraform apply -auto-approve   # or: git revert <sha> && terraform apply

# 3. Disable the SAML SP connections last, so in-flight browser logins can complete
curl -sk -u administrator:"$PF_ADMIN_PW" -X PATCH \
  https://pf-console:9999/pf-admin-api/v1/idp/spConnections/<conn-id> \
  -H 'Content-Type: application/json' -H 'X-XSRF-Header: PingFederate' \
  -d '{"active": false}'

If you must fully decommission, drain the engine nodes at Akamai first (pull them from the GTM pool), let the 5-minute access tokens expire, then stop the services. Keep the Vault-held signing keys until you have confirmed no downstream service still trusts the old JWKS kid.

Common pitfalls

Security notes

The whole design exists to shrink the blast radius of a credential. The downstream tokens are audience-bound and 5-minute-lived, so a leaked bearer token is useless against a different API and stale within minutes. The BFF authenticates to the token endpoint with mutual TLS, not a shared secret, and all signing/MTLS material is leased from HashiCorp Vault rather than stored on disk — so a node compromise does not hand over a permanent key. Put Akamai’s WAF in front of the runtime nodes to absorb credential-stuffing and assertion-replay floods before they reach PingFederate. Run Wiz / Wiz Code continuously over the IaC and the running infra to catch a drift that re-enables public network access or widens a client’s scope grant, and run CrowdStrike Falcon sensors on the PingFederate hosts for runtime threat detection feeding the SOC. A failed-exchange spike or a config drift should auto-raise a ServiceNow incident so security has a ticket, not just a log line. Finally, keep upstream IdP trust narrow: PingFederate should validate Entra/Okta tokens against the exact issuer and JWKS, and reject anything else — the broker is only as trustworthy as the IdPs it federates.

Cost notes

PingFederate is licensed per environment/throughput, so the real cost levers are operational. Right-size the engine tier: token exchange is cheap CPU, but JWKS validation and SAML signature checks add up at thousands of logins per minute — start with two engines and scale on the /as/token.oauth2 p95, not on a guess. Cache JWKS at the resource servers (they validate offline, so PingFederate is not in the per-request path — do not accidentally put it there). The Vault, Akamai, Dynatrace/Datadog, Wiz, and Falcon line items are shared platform services you are almost certainly already paying for; folding the broker into them adds marginal cost, not new contracts. The largest hidden cost is avoided: not re-platforming four business units onto a single IdP. The broker is the cheap path precisely because it lets the legacy SAML ERP, the Okta SaaS division, and the Entra corporate estate keep their own identity stores while the API mesh sees one clean, short-lived, auditable token.

PingFederateSAMLOAuthToken ExchangeSSOIdentity
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading