If you have been building AI agents on top of SAP BTP, you have probably hit this wall: your agent calls a backend REST API and every request lands in the ABAP system under a single shared service account. Audit logs record the service account, not the person behind the request, and ABAP cannot enforce per-user authorization when every call arrives under the same identity.
The fix is principal propagation — a chain of trust that flows the end user's identity from their browser session all the way through BTP Cloud Foundry, the Cloud Connector, and into the ABAP ICM layer, where a short-lived X.509 certificate establishes who is actually making the call.
This post walks through how to build exactly that, with an MCP server (a Model Context Protocol server — a lightweight HTTP service that exposes typed tools an LLM agent can call) as the entry point so your LLM agent can call ABAP REST APIs as the authenticated user.
Why Not Just Use a Service Account?
You could skip all of this and use a single ABAP technical user. It is simpler to set up. But:
- ABAP authorization objects are designed around individual users. A shared account means every call runs under the same permissions — you cannot enforce different rights for different people.
- Audit logs (SM20, SAL) record the technical user, not the person who made the request. This makes incident investigation and compliance reporting much harder.
- If an agent misbehaves or gets compromised, the blast radius is the full permissions of that service account.
Principal propagation gives you per-user authorization enforcement and full auditability at the ABAP layer, without requiring the cloud application to manage any ABAP credentials.
What We Are Building
The scope of this post is the MCP server and its principal propagation chain — everything from the MCP server down to ABAP. The browser login through the approuter to a user JWT (the top block below) is assumed to already be in place.
User browser
│ authorization_code login
▼
Approuter (SAP BTP CF) ──► XSUAA ──► issues user JWT ← assumed (not covered here)
│
│ user JWT (Bearer token)
▼
MCP Server (SAP BTP CF) ← this post
│ reads Connectivity service bindings from VCAP_SERVICES
│ sends: SAP-Connectivity-Authentication: Bearer <user JWT>
│ Proxy-Authorization: Bearer <connectivity client_credentials token>
▼
BTP Connectivity Proxy
│ tunnels over established WebSocket channel
▼
Cloud Connector
│ verifies JWT signature (XSUAA public key, synced from BTP trust config)
│ extracts user_uuid → generates short-lived X.509 cert CN=<user>
│ opens mTLS connection to ABAP; forwards cert in SSL_CLIENT_CERT header
▼
ABAP ICM (trusts the Cloud Connector as reverse proxy)
│ validates SSL_CLIENT_CERT → looks up CN=<user> in EXTID_DN
▼
ABAP business logic ←─ executes as the real end user
Every hop in this chain preserves the end user's identity. Only the user JWT crosses the cloud/on-premise boundary; the two client_credentials tokens are consumed entirely within BTP.
Prerequisites
| Component | Role |
|---|---|
| BTP subaccount (CF-enabled) | Hosts the apps and service instances |
| XSUAA service instance | Issues user JWTs via authorization_code flow |
| Connectivity service instance | Manages the Cloud Connector tunnel |
| Cloud Connector | On-premise agent that bridges BTP to the ABAP system |
| SAP ABAP system with REST APIs | Your backend |
Step 1 — Set Up the BTP Services
You need one service instance bound to the MCP server. The approuter has its own XSUAA binding (not covered here). In your manifest.yml:
# manifest.yml
applications:
- name: my-abap-mcp-server
memory: 512M
buildpacks:
- python_buildpack
command: python mcp_server.py
services:
- my-connectivity-service
The XSUAA instance (my-xsuaa) is bound to the approuter, not this app — the approuter handles login and issues the user JWT that the MCP server consumes. The MCP server never verifies JWT signatures itself; it delegates that responsibility to the Cloud Connector.
Create the instances via the BTP cockpit or CLI:
cf create-service connectivity lite my-connectivity-service
Step 2 — Configure the Cloud Connector
In your Cloud Connector administration UI:
2a. Register your BTP subaccount
Under Cloud To On-Premise → Add Subaccount. You will need your subaccount's XSUAA URL and your CF API endpoint. After registration the CC establishes a persistent WebSocket tunnel.
2b. Add the ABAP system as an accessible resource
Under Cloud To On-Premise → Access Control:
- Back-end type: ABAP
- Protocol: HTTP
- Internal host/port: your real ABAP hostname and port
- Virtual host:
abap-backend-virtual(must match the destination URL) - Principal type: X.509 Certificate (General Usage)
2c. Configure the subject pattern
Under the system's "Principal Propagation" settings:
- Subject pattern:
CN=${user_uuid}(maps theuser_uuidJWT claim to the cert CN)
The user_uuid claim in a standard XSUAA JWT is a UUID (e.g. 8f3a1b2c-4d5e-6789-abcd-ef0123456789). Inspect a decoded token from your own environment to confirm the exact value — this is what gets embedded as the cert CN and what you must map in EXTID_DN.
Step 3 — The MCP Server
This is the heart of the setup: a Python MCP server deployed to BTP CF that binds the Connectivity service, checks the caller's JWT zone ID, and routes each tool invocation through the connectivity proxy with the user's identity attached.
Why HTTP transport? MCP's stdio transport cannot carry per-request user identity — a single process handles all calls and there is no request-level header. The streamable-http transport gives each tool invocation its own HTTP request, so the caller can set Authorization: Bearer <user_jwt> and the server can validate and thread it through to the ABAP call.
The server uses Starlette middleware to check the JWT zone ID before any tool handler runs, storing the token in a contextvar so tool functions stay signature-clean — the LLM never sees a user_token parameter.
# requirements.txt
mcp[cli]==1.9.4
cfenv==0.5.3
requests==2.32.3
uvicorn==0.34.3
starlette==0.46.2
# mcp_server.py
import os
import base64
import json
import contextvars
import requests
from urllib.parse import quote
from cfenv import AppEnv
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
# ── BTP service bindings (injected by CF via VCAP_SERVICES) ──────────────────
env = AppEnv()
_conn = env.get_service(name='my-connectivity-service')
XSUAA_ZONE_ID = '<your-btp-subaccount-zone-id>' # BTP cockpit → subaccount → Overview → ID
ABAP_BASE = 'http://abap-backend-virtual:<port>/sap/rest'
# ── per-request user JWT, threaded via contextvar ─────────────────────────────
_user_jwt: contextvars.ContextVar[str] = contextvars.ContextVar('user_jwt')
# ── token helper ──────────────────────────────────────────────────────────────
def _connectivity_token() -> str:
c = _conn.credentials
r = requests.post(
f"{c['token_service_url']}/oauth/token",
data={'grant_type': 'client_credentials',
'client_id': c['clientid'], 'client_secret': c['clientsecret']},
timeout=10,
)
r.raise_for_status()
return r.json()['access_token']
# ── JWT validation ────────────────────────────────────────────────────────────
def _decode_jwt(token: str) -> dict:
segment = token.split('.')[1]
segment += '=' * (-len(segment) % 4)
return json.loads(base64.urlsafe_b64decode(segment))
def _validate_jwt(token: str) -> None:
"""Lightweight zone-ID check. Cryptographic signature verification is done by the CC."""
claims = _decode_jwt(token)
if claims.get('zid') != XSUAA_ZONE_ID:
raise ValueError(f"Token zone {claims.get('zid')!r} does not match this subaccount")
# ── ABAP call via BTP connectivity proxy ─────────────────────────────────────
def _abap(method: str, path: str, **kwargs) -> requests.Response:
user_jwt = _user_jwt.get()
c = _conn.credentials
prx = f"http://{c['onpremise_proxy_host']}:{c['onpremise_proxy_port']}"
url = f"{ABAP_BASE}/{path.lstrip('/')}"
hdrs = {
'SAP-Connectivity-Authentication': f'Bearer {user_jwt}', # CC extracts identity from this JWT
'Proxy-Authorization': f'Bearer {_connectivity_token()}', # app auth to the connectivity proxy
'Accept': 'application/json',
}
r = requests.request(method.upper(), url, headers=hdrs,
proxies={'http': prx}, timeout=30, **kwargs)
r.raise_for_status()
return r
# ── middleware: validate JWT before every MCP request ─────────────────────────
class JWTMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
auth = request.headers.get('authorization', '')
if not auth.startswith('Bearer '):
return JSONResponse({'error': 'Authorization: Bearer header required'}, status_code=401)
token = auth[7:]
try:
_validate_jwt(token)
except Exception as exc:
return JSONResponse({'error': str(exc)}, status_code=401)
tok = _user_jwt.set(token)
try:
return await call_next(request)
finally:
_user_jwt.reset(tok)
# ── MCP tool definitions ───────────────────────────────────────────────────────
mcp = FastMCP('abap-tools', stateless_http=True)
@mcp.tool()
def list_orders(customer_id: str) -> list[dict]:
"""Return open orders for a customer. Runs in ABAP under the calling user's identity."""
return _abap('GET', 'orders', params={'customerId': customer_id}).json()
@mcp.tool()
def get_order(order_id: str) -> dict:
"""Return details and line items for a single order."""
return _abap('GET', f'orders/{quote(order_id, safe="")}').json()
@mcp.tool()
def update_order_status(order_id: str, status: str) -> dict:
"""Update an order's processing status. The ABAP system enforces the calling user's auth objects."""
return _abap('POST', f'orders/{quote(order_id, safe="")}/status', json={'status': status}).json()
# ── entry point ───────────────────────────────────────────────────────────────
app = JWTMiddleware(mcp.streamable_http_app())
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host='0.0.0.0', port=int(os.environ.get('PORT', 8000)))
A few things worth calling out:
Two tokens, two purposes. The user JWT carries identity and is the only token that crosses the cloud/on-premise boundary. The connectivity client_credentials token authenticates the CF app to the connectivity proxy (it never leaves BTP). No Destination service is involved — the virtual hostname and port are configured directly in the Cloud Connector and hardcoded in ABAP_BASE.
SAP-Connectivity-Authentication is the magic header. The BTP connectivity proxy reads this header and passes it through the WebSocket tunnel to the Cloud Connector. The Cloud Connector's JWT validator decodes it, verifies the RS256 signature against the XSUAA public key, extracts user_uuid, and generates the per-user X.509 cert from that value.
The MCP server connects to the Connectivity Proxy over plain HTTP. The ABAP_BASE URL and proxy address both use http:// — there is no TLS at this hop. TLS and the mTLS handshake with the ABAP ICM are handled entirely by the Cloud Connector and Connectivity Proxy; the Python process is not part of those connections.
Zone ID validation is lightweight on purpose. The MCP server does not verify JWT signatures — that is the Cloud Connector's job and it does it cryptographically. The zid check is a fast guard against tokens from a completely different XSUAA tenant hitting this endpoint.
Token lifetime. XSUAA authorization_code JWTs are typically valid for 12 hours. Long agentic loops may outlive a token. The middleware does not check exp — expiry is enforced by the Cloud Connector when it verifies the JWT signature, causing the ABAP call to fail. Design your agent framework to handle auth failures and trigger a token refresh rather than retrying blindly.
Connecting an agent
Any MCP client that speaks streamable HTTP can consume this server — point it at the deployed route and pass the user's JWT as a bearer token. With LangChain's MCP adapter, for example:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"abap": {
"transport": "streamable_http",
"url": "https://my-abap-mcp-server.cfapps.<region>.hana.ondemand.com/mcp",
"headers": {"Authorization": f"Bearer {user_jwt}"},
}
})
tools = await client.get_tools() # hand these to your agent / LLM
The key point: the Authorization header carries the end user's JWT, not a service credential. Every tool call the agent makes then executes in ABAP under that user's identity — which is the whole reason for the principal propagation chain.
Step 4 — ABAP Side Configuration
This is where most setups stall. Four things must all be in place.
4a. STRUST — Import the Cloud Connector's CA certificates
The ABAP system needs to trust two certificate authorities:
-
The CA that issued the Cloud Connector's System Certificate (e.g.
SAPNetCA_G2). This is used for the mTLS handshake between the CC and the ABAP ICM. -
The Cloud Connector's Local CA (
CN=SCC-Local-CA). This is used to validate the short-lived per-user X.509 certs that the CC generates. Download this from your CC admin UI: Configuration → On Premise → CA Certificate → Download DER.
Import both into the SSL Server Standard PSE in transaction STRUST.
4b. RZ10 — Trust the Cloud Connector as a reverse proxy
By default, ABAP ICM strips any SSL_CLIENT_CERT header from incoming requests (a security measure). You must explicitly list the Cloud Connector as a trusted reverse proxy so ICM accepts the forwarded user cert.
In transaction RZ10, open the DEFAULT profile, Extended Maintenance, and add:
icm/trusted_reverse_proxy_0 = SUBJECT="CN=<your-cloud-connector-cn>, O=<org>, C=<country>", ISSUER="CN=<issuer-cn>, O=SAP, ..."
Use the exact Subject and Issuer from the CC's System Certificate. After saving, restart ICM via SMICM → Administration → ICM → Exit Hard → Global.
If you skip this step, the CC will generate the user cert correctly, forward it in SSL_CLIENT_CERT, and ABAP will silently discard it. The request will still reach ABAP but as an anonymous/default user — a subtle failure that is easy to miss.
4c. EXTID_DN — Map certificate subjects to SAP users
In transaction EXTID_DN (or via program RSUSREXT), add a mapping entry:
| Field | Value |
|---|---|
| External ID Type | DN |
| External ID | CN=<user_uuid value> |
| User | SAP user account |
For example, if your user_uuid is 8f3a1b2c-4d5e-6789-abcd-ef0123456789, the entry would be CN=8f3a1b2c-4d5e-6789-abcd-ef0123456789 → SAP user JDOE.
For production with many users, use CERTRULE to set up a pattern-based mapping rule (e.g. extract CN and map to the SAP user with the same ID) rather than maintaining individual entries.
4d. SICF — Enable certificate logon on the service
In transaction SICF, navigate to your REST API's ICF service node, open its properties, and verify that "Logon Through SSL Certificate" is enabled as an allowed logon method.
Verifying end to end
Call one of your MCP tools with a valid user JWT and then check SM20 (Security Audit Log) in the ABAP system. If the session shows the actual user — not a technical account — principal propagation is working. If it shows the wrong user or is anonymous, work through the debugging checklist below.
The Six Trust Boundaries in One Diagram
[BTP Subaccount]
│
│ 1. WebSocket tunnel (registered CC)
▼
[Cloud Connector]
│
│ 2. XSUAA public key (synced from BTP trust config)
│ → verifies JWT signature
│
│ 3. mTLS to ABAP ICM
│ (CC presents System Cert; ABAP trusts its CA via STRUST)
▼
[ABAP ICM]
│
│ 4. Trusted reverse proxy param (RZ10)
│ → accepts SSL_CLIENT_CERT header from CC
│
│ 5. Local CA in STRUST
│ → validates the forwarded short-lived user cert
│
│ 6. EXTID_DN / CERTRULE
│ → maps CN=<user_uuid> to a SAP user account
▼
[ABAP business logic — executing as the real user]
All six must be in place. A failure at any boundary usually manifests as the request completing but the ABAP session belonging to the wrong user or failing authorization.
Debugging Checklist
| Symptom | Likely cause |
|---|---|
| TLS handshake fails between CC and ABAP | CC's System Cert CA not in STRUST |
| Request reaches ABAP as anonymous user |
trusted_reverse_proxy param missing, or ICM not restarted after RZ10 change |
| ABAP returns "unknown CA" on client cert | Cloud Connector's Local CA not imported in STRUST |
| SM20 shows "external ID not found" | No EXTID_DN entry for CN=<user_uuid>
|
| 403 after user appears to be logged in | Missing ABAP authorization objects for the propagated user |
| Cloud Connector log: "JWT cannot be verified" | XSUAA signing key not synced from BTP trust configuration |
| Cloud Connector log: "JWT cannot be verified" (intermittent) | CC key cache stale — trigger a manual trust sync in the CC admin UI |
401 from the MCP server |
User JWT zid does not match the configured zone ID |
502 from the MCP server |
Connectivity service credentials missing or proxy unreachable |
Summary
The core flow is:
- User logs in via XSUAA
authorization_codeflow → gets a JWT withuser_uuid - LLM agent calls an MCP tool, forwarding the user JWT
- MCP server on BTP CF routes the call through the connectivity proxy, setting
SAP-Connectivity-Authentication: Bearer <user_jwt> - Cloud Connector verifies the JWT, extracts
user_uuid, generates a short-lived X.509 certCN=<user_uuid> - ABAP ICM receives the request with the user cert forwarded in
SSL_CLIENT_CERT - ABAP maps the cert subject to a SAP user via EXTID_DN and runs the business logic as that user
The key insight is that identity is carried as a signed JWT token across the cloud layer, then converted to an X.509 certificate at the network boundary — a format ABAP's ICM already understands natively. The MCP server is just the latest entry point into an existing protocol.
This post is intended as guidance to illustrate how principal propagation works end to end. The code is simplified to keep the focus on the identity flow — a production deployment will need additional hardening (signature validation at the MCP layer, error handling, secrets management, logging, and so on). Treat it as a starting point, not a drop-in solution.
Top comments (0)