Every AI system that touches SAP has to prove who it is before it gets any data. The proof is a secret: an API key, a password, a client secret or a certificate. Whoever holds the secret can act as your system.
So two questions matter on every project:
Where do the secrets live? Never in code, never in a shared document, never in Git. On a developer's laptop they sit in a private .env file. In production they sit in a managed store that controls who can read them, logs every use and makes them easy to replace.
How does the system log on? There are a handful of methods, from a simple key to OAuth. OAuth is the one you will meet most in SAP. Instead of sending the secret on every call, the system swaps it once for a token: a short-lived pass that expires on its own. If a token leaks, the damage is limited to minutes or hours. If a secret leaks, it lasts until someone notices.
One more idea matters for AI assistants in particular. Does the system call SAP as itself (a technical user that sees everything it is allowed to see) or as the person who asked the question (so SAP applies that person's own authorizations)? The second is called principal propagation. It decides whether your AI assistant can show a sales clerk the credit data of a company code they normally can't open.
Take the running example from Unit 1: a job reads blocked sales orders from S/4HANA every night and asks a model to summarize them for the credit team. It needs at least two secrets: one for SAP and one for the model provider.
Here is what goes wrong when secrets are handled casually:
A key in the code goes public. A developer pastes the SAP password into the script "for now" and pushes it to a repository. Repositories get shared, forked and copied. The password now lives in every copy, including the Git history, even after someone deletes the line.
One shared super-user. Every AI job uses the same technical user with broad rights, because it was quick to set up. Nobody can tell which job did what. If its password leaks, an attacker gets all of those rights at once.
No way to rotate. A consultant leaves. Their laptop had the production client secret in a file. Changing it breaks five jobs, because nobody knows where else it is used. So nobody changes it.
Model costs on someone else's bill. A leaked model API key is used by strangers. The first sign is the invoice.
The assistant sees too much. An AI assistant calls S/4HANA with one technical user. A clerk asks about a customer outside their area, and the assistant answers, because the technical user may see everything. SAP's authorization concept was bypassed without anyone noticing.
The business decision: for every AI system, someone should be able to name each secret it uses, where it is stored, who can read it, when it was last changed, and whether SAP sees the end user or a technical user.
SAP systems and SAP BTP have standard places for each of these jobs. As of October 2026:
S/4HANA Cloud: communication arrangements. An outside system gets access through a communication arrangement, which ties a communication scenario (which APIs) to a communication system (who is calling) and a communication user (a technical user, not a person). SAP Learning lists the inbound logon methods: user and password, a certificate, or OAuth, including OAuth 2.0 with mutual TLS. On-premise S/4HANA has its own setup, done by the Basis team; ask them which methods your system offers.
SAP BTP: service keys and bindings. When an app on BTP uses a service such as the Destination service or SAP AI Core, it gets a client ID and client secret. An app deployed on BTP receives them automatically through a binding; a laptop or outside tool uses a service key copied by hand. Both are swapped for an OAuth token. SAP recommends a certificate (mutual TLS) over a client secret where possible.
SAP BTP: rotation. SAP's security guidance for its authorization service (often called XSUAA) recommends credential types that let you rotate one app's secret without breaking others, and recommends rotating regularly.
SAP BTP: Destination service. A destination stores where a target system is and how to log on to it, so the secrets stay out of the app. For OAuth destinations, SAP's documentation says the service caches the token and renews it shortly before it expires.
SAP Credential Store. A BTP service that keeps passwords, keys and keyrings for apps on Cloud Foundry and Kyma, read through a REST API. It is the BTP answer to "where do the other secrets go", such as a model provider's key.
Principal propagation. SAP BTP can forward the logged-on user's identity to the target system, so the target's own authorizations apply. SAP documents it for on-premise systems (through the SAP Cloud Connector) and for cloud systems (through OAuth flows).
Usually one key per account, not per user; often no expiry
Basic authentication
A user name and password sent with every call
Simple system-to-system calls with a communication user
The password travels on every call; only safe over HTTPS; hard to rotate
OAuth 2.0 client credentials
The system swaps its secret for a short-lived token, then sends the token
A job or service acting as itself, such as the nightly blocked-orders job
The token carries the system's rights, not a person's
OAuth 2.0 authorization code
A person logs on in the browser; the app gets a token for that person
Apps where a person is present, such as an AI assistant in a web page
More moving parts: redirects, consent, session handling
Certificate (mutual TLS)
The system proves itself with a certificate instead of a secret
Production system-to-system calls; SAP recommends it on BTP
Certificates expire and must be renewed on time
Principal propagation
The user's identity is forwarded to SAP, so SAP's own checks apply
AI assistants that read or change SAP data for a person
Needs trust set up between systems; more work up front
A quick rule: a job with no person behind it uses client credentials or a certificate. Anything acting for a person should carry that person's identity.
List every secret this system uses. Where is each one stored in development, test and production?
Who can read each secret? Can a developer read the production ones?
When was each secret last changed, and how long does it take to change one without an outage?
If a secret leaked today, what are the exact steps, and who does them?
Does any secret appear in code, in a ticket, in a chat message or in Git history? How do you check?
Which SAP user does the system log on as? What authorizations does it have, and who approved them?
When a person uses the AI assistant, does SAP see that person or a technical user? If a technical user, how do you stop the assistant from showing data the person may not see?
How long are tokens valid? Do logs ever contain tokens, keys or passwords?
Are we using OAuth flows that current security guidance still allows, such as client credentials and authorization code with PKCE, and not the old password grant?
"The repository is private, so a key in the code is fine." Private repositories get shared, cloned and copied to laptops. The key also stays in Git history after you delete it. Treat any committed secret as leaked.
"OAuth is a login screen." OAuth is a way to hand out limited, short-lived tokens. Many OAuth flows, including client credentials, never show a login screen at all.
"A token is safe to log because it expires." Most tokens are bearer tokens: anyone who holds one can use it until it expires, which can be an hour or more.
"Encoding hides the password." Basic authentication encodes the password in Base64, which anyone can reverse. Only HTTPS protects it in transit.
"One technical user for all AI jobs keeps things simple." It also means one leak exposes everything, and nobody can trace which job did what. Give each system its own user with only the rights it needs.
"Principal propagation is optional polish." For an assistant that answers questions about SAP data, it is how SAP's authorization concept keeps working. Without it, the AI decides what each person may see.
Secret: anything that proves identity: API key, password, client secret, private key, token.
Environment variable: a named value the operating system hands to a program, such as SAP_API_KEY. Code reads the name; the value stays outside the code.
.env file: a private file on a developer's computer that holds environment variables for one project. Never committed to Git.
Secret store: a managed service that keeps secrets encrypted, controls access and logs use. SAP Credential Store is SAP BTP's.
Rotation: replacing a secret with a new one on a schedule or after a leak, and retiring the old one.
OAuth 2.0: a standard for handing out access tokens so an app can call an API without sending a password each time.
Client credentials flow: the OAuth flow where a system swaps its own client ID and secret for a token. No person involved.
Authorization code flow: the OAuth flow where a person logs on in the browser and the app receives a token for that person.
Bearer token: a token that works for whoever holds it, like a cinema ticket.
Scope: a named permission written into a token, such as "read orders".
Communication arrangement: the S/4HANA Cloud setting that lets an outside system call a set of APIs with a communication user.
Principal propagation: forwarding the logged-on user's identity to the target system, so its own authorizations apply.
Pick one answer for each question. The explanation appears after you choose.
1A developer committed the SAP password to a private repository, then deleted the line the next day. What should happen now?
Answer: C. Private repositories are shared and copied, and the password stays in Git history after the line is deleted. The safe response is to change the password and retire the old one.
2What is the main business advantage of OAuth tokens over sending a password on every call?
Answer: A. The secret is used once to get a token, and the token expires on its own. If a token leaks, the damage window is short; a leaked password works until someone notices and changes it.
3A nightly job reads blocked sales orders with no person involved. Which logon method fits best?
Answer: D. A job acting as itself uses client credentials or a certificate. Giving it its own technical user keeps its rights narrow and makes its actions traceable.
4Your AI assistant answers clerks' questions using one technical user that can see all company codes. What is the risk?
Answer: B. SAP checks the technical user's rights, not the clerk's. Principal propagation forwards the person's identity so SAP's own authorizations decide what comes back.
5Which question best tests whether a vendor handles secrets well?
Answer: C. If a secret can't be changed quickly and safely, it won't be changed after a leak or when someone leaves. Fast, routine rotation is a sign the secrets are tracked and stored properly.
6Where should an AI app running on SAP BTP keep a model provider's API key in production?
Answer: D. A managed store keeps the key encrypted, limits who can read it and logs its use. Code, shared files and laptops spread copies that nobody can track or rotate.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Deep layer · 40 min read
#Mental model: a secret is a master key, a token is a day pass
Think of a hotel. The secret (client secret, password, private key) is a master key: it opens everything it was cut for, and it works until someone changes the locks. The token is a day pass printed at the front desk: it opens a few named doors, it shows who it was issued to, and it stops working tonight.
Everything in this topic follows from that picture:
Keep master keys in one locked place, and let as few people and programs touch them as possible. That is secret handling: .env on your laptop, a secret store or a platform binding in production.
Show the master key only to the front desk. In OAuth, the secret goes to one place, the token endpoint, and nowhere else. Every API call carries the day pass instead.
Ask for the narrowest pass you need, reuse it until shortly before it expires, and never write it down where others can read it, such as logs or URLs.
Decide whose pass it is. A pass issued to your program carries your program's rights. A pass that carries the person's identity lets SAP apply that person's authorizations. That second case is principal propagation.
An environment variable is a named value that the operating system passes to a program when it starts. Your code asks for a name, such as os.environ["OAUTH_CLIENT_SECRET"], and never contains the value.
How the value gets there depends on where the code runs:
Where the code runs
Where the secret comes from
Who can read it
Your laptop
A .env file, loaded by python-dotenv
You, and any program you run
A CI pipeline such as GitHub Actions
The pipeline's encrypted secret settings
The pipeline jobs you allow
An app on SAP BTP
A binding: the platform passes the bound service's credentials through the environment
That app
Anywhere, for secrets with no binding
A secret store, such as SAP Credential Store, read through its API
Apps and people granted access
Three details about .env matter in practice:
load_dotenv() copies the lines of .env into the environment. By default it does not overwrite a variable that is already set. So a value set in the terminal or by the platform wins over the file. That is what you want: the same code uses .env on your laptop and the platform's values in production.
.env must be listed in .gitignore so Git never picks it up. The setup topic did this for you.
A file named .env.example, with the same names and empty values, is safe to commit. It tells the next developer which settings to fill in.
OWASP's secrets guidance adds the production rules: keep secrets in a central, managed place; give each person and program access only to the secrets it needs; rotate automatically; let secrets expire where possible; and detect secrets in code before they are committed. It also warns that environment variables are visible to every process of that program and can end up in logs and crash dumps. That is why production systems move to bindings and stores, and why code should never print its environment.
Every method puts something into the request. Most use the Authorization header.
Method
What the request carries
Example header
API key
The key, in a header the API names
APIKey: 3f9a... (SAP Business Accelerator Hub sandbox)
Basic authentication
user:password, Base64-encoded
Authorization: Basic Q09NTV9VU0VSOnBhc3N3b3Jk
Bearer token
A token from an authorization server
Authorization: Bearer eyJhbGciOi...
Client certificate (mutual TLS)
Nothing in a header: the TLS connection itself proves identity with a certificate
(none)
Base64 is an encoding, not encryption: anyone can turn Q09NTV9VU0VSOnBhc3N3b3Jk back into the user name and password. Basic authentication and bearer tokens are only safe over HTTPS.
You already used an API key in Calling your first SAP API. The rest of this topic is about bearer tokens, and how to get one.
OAuth 2.0 is defined in RFC 6749. It names four roles:
Resource owner: whoever may grant access to the data. Often a person; in machine-to-machine cases, the organization behind the client.
Client: the program that wants the data. Your script.
Authorization server: issues tokens. On SAP BTP this is the SAP Authorization and Trust Management service (XSUAA).
Resource server: the API that holds the data and checks tokens, such as the Destination service or an S/4HANA API.
The client gets tokens from the authorization server's token endpoint. How it proves it deserves one is called the grant type. The simplest is client credentials: the client sends its own ID and secret.
sequenceDiagram
participant C as Client (your script)
participant A as Authorization server
participant R as Resource server (API)
C->>A: POST /token, grant_type=client_credentials<br/>client ID + secret (HTTP Basic)
A-->>C: access_token, token_type=bearer, expires_in=3600
C->>R: GET /orders<br/>Authorization: Bearer <token>
R-->>C: 200 + data
C->>R: GET /orders (same token, until it nearly expires)
R-->>C: 200 + data
Points from the standard that change how you write code:
The token endpoint must use TLS (HTTPS), because the secret travels in the request.
RFC 6749 prefers sending the client ID and secret with HTTP Basic. Sending them as form fields is allowed but "NOT RECOMMENDED". Some servers only accept form fields; SAP's own example for the Destination service sends them that way. The script in this topic supports both.
The client credentials grant normally returns no refresh token. When the access token expires, the client simply asks again with its secret.
Errors come back as JSON with an error code. The ones you will meet: invalid_client (wrong ID or secret), invalid_scope (you asked for a scope you may not have), unauthorized_client (this client may not use this grant) and unsupported_grant_type.
Many authorization servers, including XSUAA, issue tokens as JWTs (JSON Web Tokens): three Base64url parts joined by dots. The middle part holds claims: who issued the token (iss), whom it is for (aud), which client or user it represents (sub, client_id), which scopes it grants, and when it expires (exp, in seconds since 1 January 1970).
Two rules:
Decoding is not verifying. Anyone can decode the middle part and read the claims; you will do it in the walkthrough. Only the resource server's check of the signature, the expiry and the audience makes the claims trustworthy. Your client code may read claims for display or debugging. It must never make security decisions from claims it didn't verify.
Some tokens are opaque. A server may hand out random strings that only it can look up. Your client should not depend on the token being a JWT.
RFC 9700, the current OAuth security guidance, recommends that access tokens be audience-restricted to one resource server and limited to the scopes they need. A token for the Destination service should not open SAP AI Core.
RFC 6750 defines a bearer token as one that any party in possession can use. So:
Send it in the Authorization: Bearer header. RFC 6750 says tokens should not be passed in page URLs, because URLs end up in browser history and server logs.
Cache it and reuse it until shortly before expires_in runs out. Asking for a new token on every call is slow, puts load on the authorization server and multiplies the places the secret is sent. SAP's Destination service does exactly this for OAuth destinations: it caches the token and fetches a new one shortly before the old one expires.
Read the answer correctly.401 with error="invalid_token" means the token is expired, revoked or broken: get a new one and try once more. 403 with error="insufficient_scope" means the token is valid but doesn't carry the needed permission: a new token from the same client won't help, so stop and fix the client's scopes or roles.
Never log it. A token in a log file is a working credential until it expires. Log a short prefix at most, as the script below does.
When a person is present, the app should act as that person. The authorization code grant does that:
sequenceDiagram
participant U as User's browser
participant App as Web app (client)
participant A as Authorization server
participant R as API
U->>App: Opens the AI assistant
App->>U: Redirect to log on (client ID, scopes, redirect URI, PKCE challenge)
U->>A: Logs on (company identity provider)
A->>U: Redirect back with a one-time code
U->>App: Delivers the code
App->>A: Swap code + PKCE verifier (+ client secret) for tokens
A-->>App: Access token for this user (+ refresh token)
App->>R: Call with the user's token
The code is short-lived (RFC 6749 recommends at most 10 minutes) and useless without the client's own proof. PKCE (Proof Key for Code Exchange) adds a one-time secret that the app makes up for each logon, so a stolen code can't be swapped by anyone else. RFC 9700 says public clients (apps that can't keep a secret, such as single-page web apps) must use PKCE, and recommends it for all others.
RFC 9700 also retires two older grants. The resource owner password credentials grant, where an app collects the user's password itself, must not be used. The implicit grant, which returned tokens directly in the browser's address, should not be used. If a vendor's design relies on either, ask why.
On SAP BTP you rarely write this flow yourself: an application router in front of the app handles the user's logon with XSUAA, and your backend checks the token it receives. Building an AI API and Deploying AI apps on BTP show that setup.
Your AI app has the user's token. Now it needs data from S/4HANA. Two options:
Technical user. The app calls S/4HANA with its own credentials. S/4HANA checks the technical user's authorizations. The app must filter results per user itself, and S/4HANA's change documents show the technical user, not the person.
Principal propagation. The app passes the user's identity on. S/4HANA logs on as the user and applies that user's roles and authorizations. SAP's documentation describes this as forwarding the cloud user's identity to the remote system, with a JWT as the exchange format.
SAP BTP supports two routes: cloud to on-premise, through the Connectivity service and the SAP Cloud Connector, with a destination of type PrincipalPropagation; and cloud to cloud, with destinations of type OAuth2SAMLBearerAssertion or OAuth2JWTBearer. Both need trust set up between the systems. For an AI assistant that reads SAP data for people, this is how SAP's authorization concept keeps applying. Unit 11 covers agent permissions and SAP authorizations in depth.
OWASP's guidance gives the order. Practise it before you need it:
Revoke the leaked secret at its source, at once. For a BTP service key, delete the key in the cockpit; for a model provider key, revoke it in the provider's console.
Rotate: create a new secret and put it where the old one was (.env, the pipeline, the store).
Remove it from where it leaked. Deleting a line in Git does not remove it from history; assume anything that was pushed has been copied.
Check the logs for use of the old secret between the leak and the revocation.
The order matters. Cleaning up Git history first, while the secret still works, leaves an open door.
#Build it yourself: get a token, use it, and check your folder for leaks
You will build two small tools:
oauth_token.py gets an OAuth token with the client credentials flow, reads what is inside it, uses it to call a protected API, and reuses it until it expires. Its --sample mode starts a tiny practice authorization server and API on your own computer, so you can see every step, and every failure, with no account at all. Without --sample, it talks to a real OAuth server using settings from your .env.
find_secrets.py checks your course folder for secrets in the wrong place: a .env that Git could pick up, or a key pasted into a script.
flowchart LR
E[".env<br/>client ID + secret"] --> S["oauth_token.py"]
S -->|"1. secret, once"| A["Token endpoint"]
A -->|"2. token"| S
S -->|"3. Bearer token, many calls"| API["Protected API"]
F["find_secrets.py"] -.->|"scans"| D["course folder"]
Before you start: complete Set up your computer for this course. It creates your orchestrate-course folder with its .venv, installs requests and python-dotenv, creates .env and lists it in .gitignore. Errors, logging and debugging explains the transient and permanent failures you will see here.
For the sample runs: nothing else. Free, and they work offline.
For the real-server run: internet access to demo.duendesoftware.com, a public OAuth demo server run by Duende Software. No account, free. Its demo client ID and secret are published on its home page.
In VS Code's file list, right-click unit01, choose New File and name it oauth_token.py.
Paste the whole script below and save with Ctrl+S (Windows, Linux) or Cmd+S (macOS).
"""Secrets, API authentication and OAuth: get a token with client credentials, then use it.
How to run (from the orchestrate-course folder, with .venv turned on):
python unit01/oauth_token.py --sample local practice server; no account, works offline
python unit01/oauth_token.py --sample --show-claims also print what is inside the token
python unit01/oauth_token.py --sample --short-tokens tokens expire after 5 s, so you see renewal
python unit01/oauth_token.py --sample --wrong-secret see how a refused client looks
python unit01/oauth_token.py --sample --no-scope a token without the needed scope gets 403
python unit01/oauth_token.py a real OAuth server; settings come from .env
Settings read from .env for a real server:
OAUTH_TOKEN_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_SCOPE (optional), OAUTH_API_URL
"""
import argparse
import base64
import hashlib
import hmac
import json
import os
import secrets
import sys
import threading
import time
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
try:
import requests
except ImportError:
sys.exit("The 'requests' library is missing. Turn on .venv, then run: pip install -r requirements.txt")
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def mask(value):
"""Show only the start and the length of a secret, never the whole thing."""
if not value:
return "(empty)"
return f"{value[:4]}... ({len(value)} characters)"
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def b64url_decode(text):
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def read_claims(token):
"""Decode the middle part of a JWT. This does NOT check the signature: display only."""
parts = token.split(".")
if len(parts) != 3:
return None # not a JWT; some servers issue opaque tokens, which is fine
try:
return json.loads(b64url_decode(parts[1]))
except ValueError:
return None
class AuthError(Exception):
"""A problem a retry cannot fix: wrong client, wrong secret, missing scope."""
class NetworkError(Exception):
"""The server could not be reached."""
# ---------------------------------------------------------------------------
# Settings: where secrets come from
# ---------------------------------------------------------------------------
@dataclass
class Settings:
token_url: str
client_id: str
client_secret: str = field(repr=False) # repr=False: never printed by accident
scope: str
api_url: str
def describe(self):
return (f" token URL : {self.token_url}\n"
f" client ID : {self.client_id}\n"
f" secret : {mask(self.client_secret)}\n"
f" scope : {self.scope or '(none requested)'}\n"
f" API URL : {self.api_url}")
def settings_from_env():
try:
from dotenv import load_dotenv
except ImportError:
sys.exit("The 'python-dotenv' library is missing. Run: pip install -r requirements.txt")
load_dotenv() # copies .env lines into the environment; existing variables win
names = ["OAUTH_TOKEN_URL", "OAUTH_CLIENT_ID", "OAUTH_CLIENT_SECRET", "OAUTH_API_URL"]
missing = [n for n in names if not os.environ.get(n)]
if missing:
sys.exit(f"Missing in .env: {', '.join(missing)}. Add them (see the topic), or run with --sample.")
return Settings(
token_url=os.environ["OAUTH_TOKEN_URL"],
client_id=os.environ["OAUTH_CLIENT_ID"],
client_secret=os.environ["OAUTH_CLIENT_SECRET"],
scope=os.environ.get("OAUTH_SCOPE", ""),
api_url=os.environ["OAUTH_API_URL"],
)
# ---------------------------------------------------------------------------
# The OAuth client: swap the client secret for a token, reuse it until it expires
# ---------------------------------------------------------------------------
class TokenClient:
REFRESH_MARGIN = 30 # get a new token this many seconds before the old one expires
def __init__(self, settings, client_auth="basic"):
self.s = settings
self.client_auth = client_auth
self.http = requests.Session()
self._token = None
self._expires_at = 0.0
self._margin = self.REFRESH_MARGIN
self.token_requests = 0
def get_token(self):
if self._token and time.time() < self._expires_at - self._margin:
return self._token # still valid: reuse it, don't ask again
return self._fetch_token()
def forget_token(self):
self._token, self._expires_at = None, 0.0
def _fetch_token(self):
form = {"grant_type": "client_credentials"}
if self.s.scope:
form["scope"] = self.s.scope
auth = None
if self.client_auth == "basic": # RFC 6749 prefers HTTP Basic for the client secret
auth = (self.s.client_id, self.s.client_secret)
else: # some servers only accept the secret in the form body
form.update(client_id=self.s.client_id, client_secret=self.s.client_secret)
self.token_requests += 1
try:
reply = self.http.post(self.s.token_url, data=form, auth=auth, timeout=(5, 30))
except requests.RequestException as exc:
raise NetworkError(f"could not reach the token URL ({type(exc).__name__})") from exc
if reply.status_code != 200:
raise AuthError(explain_token_error(reply))
body = reply.json()
self._token = body["access_token"]
lifetime = int(body.get("expires_in", 300)) # if the server doesn't say, assume 5 minutes
self._expires_at = time.time() + lifetime
self._margin = min(self.REFRESH_MARGIN, lifetime // 10) # short tokens: small margin
print(f"Token request #{self.token_requests}: got a {body.get('token_type', 'bearer')} token "
f"{mask(self._token)}, valid for {lifetime} s")
return self._token
def call(self, url):
"""GET a protected URL with the token. On 'invalid_token', get a fresh token once."""
for attempt in (1, 2):
headers = {"Authorization": f"Bearer {self.get_token()}", "Accept": "application/json"}
try:
reply = self.http.get(url, headers=headers, timeout=(5, 30))
except requests.RequestException as exc:
raise NetworkError(f"could not reach the API ({type(exc).__name__})") from exc
challenge = reply.headers.get("WWW-Authenticate", "")
if reply.status_code == 401 and attempt == 1:
print(" API said 401 (token expired or revoked): getting a new token and trying once more")
self.forget_token()
continue
if reply.status_code == 401:
raise AuthError(f"API refused a brand-new token (401). {challenge}".strip())
if reply.status_code == 403:
raise AuthError("API answered 403: the token is valid but lacks the right scope or role. "
f"{challenge}".strip())
reply.raise_for_status()
return reply.json()
def explain_token_error(reply):
"""Turn the token endpoint's error into a message that says what to fix."""
try:
body = reply.json()
except ValueError:
body = {}
code = body.get("error", "")
hints = {
"invalid_client": "the client ID or secret is wrong, or the client was deleted. Check .env",
"unauthorized_client": "this client may not use the client credentials grant",
"invalid_scope": "the scope you asked for doesn't exist or isn't allowed for this client",
"unsupported_grant_type": "the server doesn't offer the client credentials grant",
"invalid_request": "a parameter is missing or wrong; try --client-auth body",
}
hint = hints.get(code, "see the server's answer above")
detail = body.get("error_description", reply.text[:200])
return f"token request refused: HTTP {reply.status_code} {code or ''}: {detail}. Likely cause: {hint}"
# ---------------------------------------------------------------------------
# Practice server for --sample: a tiny authorization server and API on your own computer
# ---------------------------------------------------------------------------
PRACTICE_CLIENT_ID = "orders-reader"
PRACTICE_SECRET = "practice-only-not-a-real-secret" # not-a-secret: public practice value
SIGNING_KEY = secrets.token_bytes(32) # made fresh each run, only inside this process
BLOCKED_ORDERS = [ # made-up, shaped like the S/4HANA Sales Order API fields used in Unit 1
{"SalesOrder": "5000101", "SoldToParty": "17100001", "TotalNetAmount": "18250.00",
"TransactionCurrency": "EUR", "OverallSDProcessStatus": "A", "HeaderBillingBlockReason": "02"},
{"SalesOrder": "5000107", "SoldToParty": "17100004", "TotalNetAmount": "4100.00",
"TransactionCurrency": "EUR", "OverallSDProcessStatus": "A", "DeliveryBlockReason": "01"},
]
def sign_jwt(claims):
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64url(json.dumps(claims).encode())
signature = hmac.new(SIGNING_KEY, f"{header}.{payload}".encode(), hashlib.sha256).digest()
return f"{header}.{payload}.{b64url(signature)}"
def verify_jwt(token):
"""What a resource server does: check signature, expiry and audience before trusting claims."""
try:
header, payload, signature = token.split(".")
except ValueError:
return None, "malformed token"
expected = hmac.new(SIGNING_KEY, f"{header}.{payload}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(b64url(expected), signature):
return None, "bad signature"
claims = json.loads(b64url_decode(payload))
if claims["exp"] < time.time():
return None, "token expired"
if claims["aud"] != "practice-orders-api":
return None, "token is for another API"
return claims, ""
class PracticeHandler(BaseHTTPRequestHandler):
token_lifetime = 3600
def log_message(self, *args): # keep the terminal quiet
pass
def send_json(self, status, body, extra_headers=None):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
for name, value in (extra_headers or {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_POST(self): # the token endpoint
if self.path != "/oauth/token":
return self.send_json(404, {"error": "not_found"})
form = {k: v[0] for k, v in parse_qs(self.rfile.read(
int(self.headers.get("Content-Length", 0))).decode()).items()}
client_id, secret = form.get("client_id"), form.get("client_secret")
auth = self.headers.get("Authorization", "")
if auth.startswith("Basic "):
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
if client_id != PRACTICE_CLIENT_ID or not hmac.compare_digest(secret or "", PRACTICE_SECRET):
return self.send_json(401, {"error": "invalid_client",
"error_description": "Client authentication failed"},
{"WWW-Authenticate": 'Basic realm="practice"'})
if form.get("grant_type") != "client_credentials":
return self.send_json(400, {"error": "unsupported_grant_type"})
requested = set(form.get("scope", "").split())
if requested - {"orders.read"}:
return self.send_json(400, {"error": "invalid_scope",
"error_description": "Unknown scope requested"})
now = int(time.time())
token = sign_jwt({"iss": "practice-auth-server", "sub": client_id, "client_id": client_id,
"aud": "practice-orders-api", "scope": " ".join(sorted(requested)),
"iat": now, "exp": now + self.token_lifetime})
self.send_json(200, {"access_token": token, "token_type": "bearer",
"expires_in": self.token_lifetime, "scope": " ".join(sorted(requested))})
def do_GET(self): # the protected API
if self.path != "/api/blocked-orders":
return self.send_json(404, {"error": "not_found"})
auth = self.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return self.send_json(401, {"error": "no token"}, {"WWW-Authenticate": 'Bearer realm="practice"'})
claims, problem = verify_jwt(auth[7:])
if not claims:
return self.send_json(401, {"error": "invalid_token"}, {
"WWW-Authenticate": f'Bearer error="invalid_token", error_description="{problem}"'})
if "orders.read" not in claims["scope"].split():
return self.send_json(403, {"error": "insufficient_scope"}, {
"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="orders.read"'})
self.send_json(200, {"value": BLOCKED_ORDERS, "read_by": claims["client_id"]})
def start_practice_server(token_lifetime):
PracticeHandler.token_lifetime = token_lifetime
server = ThreadingHTTPServer(("127.0.0.1", 0), PracticeHandler) # port 0: pick any free port
threading.Thread(target=server.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{server.server_address[1]}"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def show_result(data):
if isinstance(data, dict) and isinstance(data.get("value"), list):
print(f" API answered with {len(data['value'])} records:")
for row in data["value"][:5]:
print(" ", {k: row[k] for k in list(row)[:4]})
else:
text = json.dumps(data, indent=2)
print(" API answered:\n" + "\n".join(" " + line for line in text.splitlines()[:20]))
def main():
parser = argparse.ArgumentParser(description="Get an OAuth token with client credentials and use it.")
parser.add_argument("--sample", action="store_true", help="use the local practice server (no account)")
parser.add_argument("--show-claims", action="store_true", help="print the token's claims (not verified)")
parser.add_argument("--short-tokens", action="store_true", help="--sample: tokens expire after 5 s")
parser.add_argument("--wrong-secret", action="store_true", help="--sample: send a wrong secret")
parser.add_argument("--no-scope", action="store_true", help="--sample: ask for no scope")
parser.add_argument("--client-auth", choices=["basic", "body"], default="basic",
help="send the secret as HTTP Basic (default) or in the form body")
args = parser.parse_args()
if args.sample:
base = start_practice_server(5 if args.short_tokens else 3600)
settings = Settings(token_url=f"{base}/oauth/token", client_id=PRACTICE_CLIENT_ID,
client_secret="wrong-secret" if args.wrong_secret else PRACTICE_SECRET, # not-a-secret
scope="" if args.no_scope else "orders.read",
api_url=f"{base}/api/blocked-orders")
print("Practice server started on your computer (made-up data).")
else:
settings = settings_from_env()
print("Settings:\n" + settings.describe())
client = TokenClient(settings, client_auth=args.client_auth)
try:
token = client.get_token()
if args.show_claims:
claims = read_claims(token)
if claims is None:
print(" This token is opaque (not a JWT); only the server can read it.")
else:
print(" Claims inside the token (decoded, NOT verified):")
for name, value in claims.items():
print(f" {name}: {value}")
print("Call 1:")
show_result(client.call(settings.api_url))
print("Call 2:")
client.call(settings.api_url)
print(f" OK. Token requests so far: {client.token_requests} (the token was reused)")
if args.short_tokens:
print("Waiting 6 s so the token expires...")
time.sleep(6)
print("Call 3:")
client.call(settings.api_url)
print(f" OK. Token requests so far: {client.token_requests} (an expired token was replaced)")
except AuthError as exc:
print(f"Stopped: {exc}")
return 2
except NetworkError as exc:
print(f"Stopped: {exc}. Check your network or proxy, or run with --sample.")
return 3
print("Done. The secret was sent only to the token URL; the API only ever saw the token.")
return 0
if __name__ == "__main__":
sys.exit(main())
On macOS or Linux, use python3 if python is not found.
You should see something like this (the port number and token length vary):
Practice server started on your computer (made-up data).
Settings:
token URL : http://127.0.0.1:44841/oauth/token
client ID : orders-reader
secret : prac... (31 characters)
scope : orders.read
API URL : http://127.0.0.1:44841/api/blocked-orders
Token request #1: got a bearer token eyJh... (321 characters), valid for 3600 s
Call 1:
API answered with 2 records:
{'SalesOrder': '5000101', 'SoldToParty': '17100001', 'TotalNetAmount': '18250.00', 'TransactionCurrency': 'EUR'}
{'SalesOrder': '5000107', 'SoldToParty': '17100004', 'TotalNetAmount': '4100.00', 'TransactionCurrency': 'EUR'}
Call 2:
OK. Token requests so far: 1 (the token was reused)
Done. The secret was sent only to the token URL; the API only ever saw the token.
Read it from top to bottom. The secret is never printed in full. The script asked for a token once, then made two API calls with it.
Check three things: aud names the one API this token is for; scope holds only orders.read; exp minus iat is 3600 seconds, one hour. Decoding needed no key at all, which is why a token must be treated as readable by anyone who sees it.
The practice server now issues tokens that last 5 seconds. The script waits 6 seconds before the third call. The end of the output shows a second token request:
Waiting 6 s so the token expires...
Call 3:
Token request #2: got a bearer token eyJh... (321 characters), valid for 5 s
OK. Token requests so far: 2 (an expired token was replaced)
The script noticed the token was about to expire and asked for a new one before calling the API. That is the same behavior SAP documents for its Destination service.
#Step 6: Watch the two failures you must tell apart
Stopped: token request refused: HTTP 401 invalid_client: Client authentication failed. Likely cause: the client ID or secret is wrong, or the client was deleted. Check .env
A token without the needed scope:
python unit01/oauth_token.py --sample --no-scope
Token request #1: got a bearer token eyJh... (307 characters), valid for 3600 s
Call 1:
Stopped: API answered 403: the token is valid but lacks the right scope or role. Bearer error="insufficient_scope", scope="orders.read"
Both runs end with exit code 2, and neither retries. The first fails at the token endpoint: the secret is wrong. The second gets a token but fails at the API: the client lacks a permission. Retrying won't fix either; a person must fix the settings.
Now point the same code at a real authorization server on the internet.
Open .env in VS Code and add these lines at the end. They are the public demo values from the Duende demo server's home page, so they are safe to share; real values never are.
Success looks like the sample run, but with the demo server's claims. The demo page lists a one-hour token lifetime for this client, so expect valid for 3600 s, and claims that include m2m as the client and api as the scope. The test API answers Call 1 with JSON about the token it received.
If you see Stopped: could not reach the token URL (ProxyError) or (ConnectionError), your network blocks the site. That is common on company networks. The sample runs still show everything the real run does.
Optional, with your SAP BTP trial. If you created a Destination service key in SAP BTP foundations for AI builders, you can point the script at XSUAA instead. Replace the five lines with values from that key: OAUTH_TOKEN_URL is the key's url followed by /oauth/token; OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET are clientid and clientsecret; leave OAUTH_SCOPE empty; OAUTH_API_URL is the key's uri followed by /destination-configuration/v1/subaccountDestinations. Then run python unit01/oauth_token.py --client-auth body --show-claims, which sends the secret as form fields, as in SAP's own example. The answer is the list of destinations in your subaccount.
In unit01, create a new file named find_secrets.py, paste the script below and save it.
"""Look for secrets that ended up in the wrong place in your course folder.
How to run (from the orchestrate-course folder):
python unit01/find_secrets.py scan this folder
python unit01/find_secrets.py PATH scan another folder
It checks two things:
1. .env is ignored by Git and has never been committed.
2. No other file contains something that looks like a key, secret, token or private key.
Built-in modules only. Findings are printed masked, so the output is safe to share.
A line that holds a deliberate, public example value can carry the marker not-a-secret
"""
import re
import subprocess
import sys
from pathlib import Path
SKIP_DIRS = {".git", ".venv", "venv", "__pycache__", "node_modules", "logs", ".ipynb_checkpoints"}
SKIP_FILES = {".env"} # the one place secrets are allowed on your computer
TEXT_SUFFIXES = {".py", ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini",
".ipynb", ".sh", ".ps1", ".js", ".ts", ".html", ".csv", ".example", ""}
PATTERNS = [
("private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
("Anthropic API key", re.compile(r"sk-ant-[A-Za-z0-9_\-]{10,}")),
("JSON Web Token", re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{5,}")),
("bearer token", re.compile(r"Bearer\s+[A-Za-z0-9_\-\.=]{20,}")),
("BTP service key secret", re.compile(r'"clientsecret"\s*:\s*"[^"]{8,}"')),
("assigned secret", re.compile(
r"(?i)(api[_-]?key|apikey|secret|password|passwd|token)[\"']?\s*[:=]\s*[\"'][^\"'\s]{8,}[\"']")),
]
def mask(text):
return text[:6] + "..." if len(text) > 6 else "***"
def git(folder, *args):
try:
return subprocess.run(["git", *args], cwd=folder, capture_output=True, text=True)
except FileNotFoundError:
return None
def check_env_file(folder):
problems = []
result = git(folder, "rev-parse", "--is-inside-work-tree")
if result is None or result.returncode != 0:
print("LATER Git: this folder is not a Git repository yet, so .env can't leak through Git")
return problems
if git(folder, "check-ignore", "-q", ".env").returncode == 0:
print("OK .env is listed in .gitignore")
else:
print("MISSING .env is NOT ignored: add a line .env to .gitignore")
problems.append(".gitignore")
history = git(folder, "log", "--all", "--oneline", "--", ".env").stdout.strip()
if history:
print("MISSING .env appears in your Git history: treat every key in it as leaked and rotate it")
problems.append("history")
else:
print("OK .env has never been committed")
return problems
def scan(folder):
findings = []
for path in sorted(folder.rglob("*")):
if any(part in SKIP_DIRS for part in path.relative_to(folder).parts):
continue
if not path.is_file() or path.name in SKIP_FILES or path.suffix.lower() not in TEXT_SUFFIXES:
continue
try:
lines = path.read_text(encoding="utf-8").splitlines()
except (UnicodeDecodeError, OSError):
continue
for number, line in enumerate(lines, start=1):
if "not-a-secret" in line:
continue
for kind, pattern in PATTERNS:
match = pattern.search(line)
if match:
findings.append((path.relative_to(folder), number, kind, mask(match.group(0))))
break
return findings
def main():
folder = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
print(f"Checking {folder}")
problems = check_env_file(folder)
findings = scan(folder)
for file, number, kind, preview in findings:
print(f"MISSING {file}:{number}: looks like: {kind} ({preview})")
if not findings:
print("OK no secrets found outside .env")
if problems or findings:
print("Fix: move each value into .env, read it with os.environ, and rotate any key that was shared.")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Run it:
python unit01/find_secrets.py
In a healthy course folder you see:
Checking /Users/you/orchestrate-course
OK .env is listed in .gitignore
OK .env has never been committed
OK no secrets found outside .env
If your folder isn't a Git repository yet, the first check prints LATER instead; Git and GitHub for AI engineers sets that up.
See it catch a leak. Create a file unit01/leak_test.py with this one line, save it, and run the checker again:
API_KEY = "abcd1234efgh5678"
MISSING unit01/leak_test.py:1: looks like: assigned secret (API_KE...)
Fix: move each value into .env, read it with os.environ, and rotate any key that was shared.
Delete unit01/leak_test.py and run the checker once more. It should be all OK again.
This checker is a teaching tool with a handful of patterns. Professional scanners know far more key formats, and OWASP recommends running one before every commit.
Shows the first four characters and the length of a secret or token, never the whole value
Settings
Holds the five settings; field(repr=False) keeps the secret out of any accidental print
settings_from_env
Loads .env with load_dotenv(), names every missing setting, and stops before any network call
TokenClient.get_token
Returns the cached token if it is still valid, with a safety margin; otherwise asks for a new one
TokenClient._fetch_token
POSTs grant_type=client_credentials (and scope) to the token URL, with the secret as HTTP Basic or in the form body; times out after 5 s to connect and 30 s to read
TokenClient.call
Sends Authorization: Bearer; on 401 gets one fresh token and retries once; on 403 stops
explain_token_error
Turns OAuth error codes such as invalid_client and invalid_scope into a likely cause
read_claims
Decodes the JWT's middle part for display; doesn't check the signature
PracticeHandler, start_practice_server
The --sample authorization server and API, on a free port of 127.0.0.1
sign_jwt, verify_jwt
How the practice server makes tokens and how its API checks signature, expiry and audience
main
Runs the steps, prints what happened, returns exit code 0 (OK), 2 (fix the settings) or 3 (network)
The secret goes to one place. Only _fetch_token uses the secret, and only to talk to the token URL. Every API call carries the token.
Fail fast on settings. Missing names are listed before any call, so you never debug a network error that was really a typo in .env.
One retry, for one reason. A 401 from the API may mean the token expired early or was revoked, so one fresh token is worth a try. A second 401, a 403 or a refused secret is permanent.
Print the shape, not the value.mask shows enough to tell two tokens apart and nothing an attacker could use.
Sample and real share the client. Only the settings change, so what you watched against the practice server is the code that runs against a real one.
In S/4HANA Cloud, outside access to APIs is set up per integration. SAP Learning describes the pieces:
Object
What it is
Communication scenario
The integration use case: which inbound and outbound APIs belong together. SAP delivers them preloaded in the system
Communication system
The calling or called system, with its technical details and how messages are authenticated
Communication user
A technical user, not a person, that the calling system logs on as
Communication arrangement
Ties a scenario to a system and its user; this is what actually opens the APIs
The inbound authentication methods SAP Learning lists are user and password (basic), a certificate, and OAuth (OAuth 2.0, and OAuth 2.0 with mutual TLS). For an AI app, prefer OAuth or a certificate, give each app its own communication system and user, and choose the narrowest scenario that covers the APIs it needs. On-premise S/4HANA uses different configuration, owned by the Basis team.
When your app uses a BTP service, its credentials come either from a binding (deployed apps; the platform passes them through the environment) or a service key (outside callers, like your laptop). Both hold a clientid, a clientsecret and a url for the token endpoint. SAP's documentation for the Destination service shows the client credentials call to <url>/oauth/token, then a Bearer call to the service, and recommends mutual TLS with an X.509 client certificate over the secret as the more secure option.
SAP's security considerations for XSUAA add rotation rules, as of October 2026:
Use the credential types binding-secret or x509, so each binding has its own secret that can be rotated without affecting other bindings of the same instance. SAP states that bindings created from 19 January 2026 use one of these types.
Remove older instance secrets, which can't be rotated.
Rotate secrets regularly.
Keep token validity as short as possible, but SAP recommends not less than 30 minutes.
A destination of type OAuth2ClientCredentials moves the client credentials flow out of your code. The main properties, from SAP's documentation:
Property
Meaning
URL
The protected API to call
Authentication
OAuth2ClientCredentials
clientId, clientSecret
The client's credentials at the target's authorization server
tokenServiceURL
The token endpoint
scope
Optional space-separated scopes to request
ProxyType
Internet, or OnPremise to go through the Cloud Connector
A sketch of such a destination, in the JSON form the cockpit can import (values are placeholders):
{
"Name": "s4-orders-oauth",
"Type": "HTTP",
"URL": "https://my-s4.example.com/sap/opu/odata/sap/API_SALES_ORDER_SRV",
"ProxyType": "Internet",
"Authentication": "OAuth2ClientCredentials",
"tokenServiceURL": "https://my-s4.example.com/oauth/token",
"clientId": "<client ID from the target system>",
"clientSecret": "<entered in the cockpit, never in Git>"
}
The Destination service then fetches, caches and renews the token for you. Your app asks for the destination by name and receives the target URL and a ready token.
For principal propagation, the destination types change: PrincipalPropagation for on-premise systems through the Cloud Connector, and OAuth2SAMLBearerAssertion or OAuth2JWTBearer for cloud systems. In each case the app passes the user's token when it asks for the destination, and the target system logs the user on.
Not every secret has a binding. A model provider's API key, or a password for a non-SAP system, still needs a home. SAP Credential Store is SAP BTP's repository for passwords, keys and keyrings, for apps on Cloud Foundry and Kyma. Apps read them through a REST API, and service keys let outside apps use it. SAP lists a free plan with small limits, and one service instance per space.
Bindings for BTP services; SAP Credential Store for the rest
Getting and caching OAuth tokens
A token client like TokenClient in this topic
OAuth destinations in the Destination service; SAP's SDKs for AI Core
Logging users on to your web app
Writing the authorization code flow yourself (not advised)
Application router with XSUAA and your identity provider
Calling S/4HANA as the user
Very hard to do safely yourself
Principal propagation destinations, with the Cloud Connector on-premise
Calling non-SAP APIs from a script or CI job
A token client and the pipeline's secret settings
Destinations, if the job runs on BTP
The pattern: write your own token client while you learn and for tools outside BTP. Once code runs on BTP, hand secrets and tokens to bindings, destinations and the application router, so they never pass through your code.
Least privilege. One client or communication user per app and per environment (development, test, production). Only the scopes, roles and communication scenarios it needs.
Rotation without fear. Know every place a secret is used. Prefer credential types that rotate per binding. Rehearse a rotation in test before you need one in production.
Certificates over secrets. Where SAP or the target supports mutual TLS, use it, and track certificate expiry dates so renewal is never a surprise.
User identity for assistants. If the AI acts for a person, carry that person's identity to SAP with principal propagation, and keep the technical user for jobs with no person behind them.
Logs and traces. Mask secrets and tokens in every log, error message and trace, including request dumps. The masking filter from Errors, logging and debugging is a start.
Token lifetime. Short-lived tokens limit damage. Follow SAP's guidance for XSUAA token validity and don't raise lifetimes just to avoid re-authentication.
Detection. Run a secret scanner before every commit and in the pipeline. Turn on your Git host's secret scanning where it is available.
Clean core. Integration goes through released APIs and communication arrangements, never through a shared dialog user's password.
Printing the environment to debug.print(os.environ) puts every secret on screen and in any log that captures it.
Putting tokens in URLs. Query strings end up in proxy logs, browser history and monitoring tools.
A new token per call. Slow, and it sends the secret far more often than needed. Cache until shortly before expiry.
Trusting decoded claims. Reading scope from an unverified token to decide what a user may do is a security hole. Only the server that verifies the signature may decide.
Retrying a refused secret. Repeated invalid_client failures help nobody and can trigger lockouts or alerts.
One secret for all environments. A test leak becomes a production leak.
Deleting the secret from Git and calling it done. Revoke and rotate first; history cleanup is the last step.
.env committed before .gitignore. Adding .env to .gitignore afterwards doesn't remove it from history. The checker's second line catches this.
Using the password grant because it is easy. RFC 9700 says it must not be used.
Later units call SAP's sandbox with an API key, company systems with a communication user, and BTP services with OAuth. You will build one small module, auth.py, so that every later script asks it for headers and never handles a secret itself.
Before you start: finish the Build it yourself section above, so unit01/oauth_token.py exists.
In unit01, create auth.py with this code and save it:
"""One place that decides how your code logs on to an API.
Later scripts call auth_headers() and never touch keys themselves.
AUTH_METHOD in .env picks the method: apikey, basic or oauth.
Try it (from the orchestrate-course folder):
python unit01/auth.py --sample prints the headers each method would send, masked
"""
import base64
import os
import sys
sys.path.insert(0, os.path.dirname(__file__)) # so "oauth_token" is found when run from the course folder
from oauth_token import Settings, TokenClient, mask # noqa: E402
_oauth_client = None # one TokenClient per run, so its token is reused
def auth_headers(method=None):
"""Return the HTTP headers for the chosen logon method. Secrets come only from the environment."""
method = (method or os.environ.get("AUTH_METHOD", "apikey")).lower()
if method == "apikey": # SAP Business Accelerator Hub sandbox style
return {"APIKey": os.environ["SAP_API_KEY"]}
if method == "basic": # a communication user and password
pair = f"{os.environ['API_USER']}:{os.environ['API_PASSWORD']}"
return {"Authorization": "Basic " + base64.b64encode(pair.encode()).decode()}
if method == "oauth": # client credentials, token cached by TokenClient
global _oauth_client
if _oauth_client is None:
_oauth_client = TokenClient(Settings(
token_url=os.environ["OAUTH_TOKEN_URL"], client_id=os.environ["OAUTH_CLIENT_ID"],
client_secret=os.environ["OAUTH_CLIENT_SECRET"], scope=os.environ.get("OAUTH_SCOPE", ""),
api_url=os.environ.get("OAUTH_API_URL", "")))
return {"Authorization": "Bearer " + _oauth_client.get_token()}
raise ValueError(f"Unknown AUTH_METHOD {method!r}: use apikey, basic or oauth")
def masked(headers):
return {name: (value.split(" ")[0] + " " + mask(value.split(" ")[-1])) if " " in value else mask(value)
for name, value in headers.items()}
if __name__ == "__main__":
if "--sample" in sys.argv:
from oauth_token import PRACTICE_CLIENT_ID, PRACTICE_SECRET, start_practice_server
base = start_practice_server(3600)
os.environ.update({ # made-up values for this demo only, never real ones
"SAP_API_KEY": "sample-api-key-123456", # not-a-secret
"API_USER": "COMM_USER_DEMO",
"API_PASSWORD": "sample-password-123", # not-a-secret
"OAUTH_TOKEN_URL": f"{base}/oauth/token",
"OAUTH_CLIENT_ID": PRACTICE_CLIENT_ID, "OAUTH_CLIENT_SECRET": PRACTICE_SECRET,
"OAUTH_SCOPE": "orders.read"})
else:
from dotenv import load_dotenv
load_dotenv()
default = os.environ.get("AUTH_METHOD", "apikey")
for name in (["apikey", "basic", "oauth"] if "--sample" in sys.argv else [default]):
try:
print(f"{name}: {masked(auth_headers(name))}")
except KeyError as missing:
print(f"{name}: setting {missing} is not in .env")
Run it on sample values:
python unit01/auth.py --sample
You should see three masked header lines, one per method:
Run it with your real settings. With no AUTH_METHOD in .env, it uses your SAP sandbox key:
python unit01/auth.py
You should see apikey: {'APIKey': '....'} with the first four characters of your key. If you see setting 'SAP_API_KEY' is not in .env, add the key as in the setup topic.
Add the line AUTH_METHOD="oauth" to .env and run python unit01/auth.py again. It now gets a token from the server in your OAUTH_ settings from Step 7. Remove the line afterwards, so the default stays apikey.
Create a file .env.example in the course folder that lists every setting name your .env uses, with empty values, for example OAUTH_CLIENT_SECRET="". This file is meant to be committed: it tells the next person what to fill in.
Run python unit01/find_secrets.py. Fix anything it reports.
Commit unit01/auth.py and .env.example, then run git status and confirm .env is not listed.
Done when:python unit01/auth.py --sample prints three masked header lines, python unit01/find_secrets.py prints only OK lines, and your repository contains .env.example with names but no values. Later units import auth_headers() instead of reading keys directly.
Pick one answer for each question. The explanation appears after you choose.
1Your script loads .env with load_dotenv(), but the platform has already set OAUTH_CLIENT_SECRET. Which value does the script use?
Answer: B. By default load_dotenv() only sets variables that aren't already set. That lets the same code use .env on a laptop and the platform's values in production.
2In the client credentials flow, where does the client secret go?
Answer: C. The secret is shown only to the authorization server's token endpoint. API calls carry the token, which expires on its own, so the secret travels as little as possible.
3Your script decodes the token and sees scope: orders.read. What does that prove?
Answer: D. Anyone can decode a JWT's middle part, and anyone could forge one. Only the resource server's check of signature, expiry and audience makes the claims trustworthy, so clients must not make security decisions from decoded claims.
4The API answers 403 with error="insufficient_scope". What should your code do?
Answer: A. A 403 means the token is valid but lacks a permission. A new token from the same client carries the same scopes, so only changing the client's scopes or roles helps. A 401 invalid_token is the case where one fresh token is worth trying.
5Why does TokenClient reuse the token until shortly before it expires?
Answer: B. A new token per call adds latency, loads the authorization server and multiplies the times the secret is sent. SAP's Destination service does the same for OAuth destinations: it caches the token and renews it shortly before expiry.
6A vendor's design has the AI web app collect the user's SAP password and swap it for a token. What do you say?
Answer: D. That is the resource owner password credentials grant, and RFC 9700 says it must not be used. The authorization code flow with PKCE lets the user log on at the identity provider, so the app never sees the password.
7An AI assistant on SAP BTP must show each clerk only the sales orders SAP lets them see. Which setup fits?
Answer: C. Principal propagation passes the logged-on user's identity to S/4HANA, which applies that user's own authorizations. A technical user would see everything its rights allow, and the app would have to filter per user itself.
8You find a BTP service key's clientsecret in a pushed commit. What do you do first?
Answer: B. Revoke first: while the secret still works, anyone who copied it can use it. Then put the new key in .env, clean up where it leaked, and check the logs for use of the old one.
Ask a question
Testing: only staff see this
Stuck on something in this layer? Ask it here. Questions are answered in the order they arrive, and the answer appears under My questions.
Sign in (free) to ask a question. You can ask anonymously.
Sources
RFC 6749: The OAuth 2.0 Authorization Framework (IETF)— the four roles; authorization code and client credentials grants; TLS required at the token endpoint; HTTP Basic preferred for the client secret, body parameters not recommended; refresh tokens never sent to resource servers; error codes
RFC 6750: OAuth 2.0 Bearer Token Usage (IETF)— definition of a bearer token (any party in possession can use it); Authorization Bearer header preferred; tokens should not be passed in page URLs; invalid_token (401) and insufficient_scope (403)
RFC 9700: Best Current Practice for OAuth 2.0 Security (IETF, January 2025)— password grant must not be used; implicit grant should not be used; PKCE required for public clients and recommended for confidential ones; audience-restricted tokens; exact redirect URI matching; sender-constrained tokens (mTLS, DPoP)
Secrets Management Cheat Sheet (OWASP)— centralize secrets; least privilege; automated rotation; expiry; detection before commit; on a leak revoke, rotate, delete from history, review access logs; environment variable risks