Amish Kushwaha - October 19, 2026

btp-go Part 2 — Foundations: Service Bindings and XSUAA

Part 2 of 5 in the btp-go series. Part 1 — Announcing btp-go


Before you can call any SAP BTP service — XSUAA, Connectivity, Destination — your application needs to know where those services live and how to authenticate against them. That information comes from service bindings. On Cloud Foundry it arrives as a JSON blob in an environment variable. On Kyma it arrives as a tree of files mounted into the pod’s filesystem. The credential format is identical; the delivery mechanism is entirely different.

This post covers how btp-go’s binding and xsuaa modules handle both environments, and how the library avoids the common pitfall of thundering-herd token requests when multiple goroutines start up simultaneously.

One note for anyone coming from a pre-1.0 release: the btp-go 1.0.0 release redesigned the binding module. A binding.Provider now returns a raw map[string]string, and the typed extractors (binding.XSUAA, binding.Destination, binding.Connectivity) are package-level functions that take a provider. The snippets below use that shape. To install the two modules covered here, run go get github.com/bluefunda/btp-go/binding@latest and go get github.com/bluefunda/btp-go/xsuaa@latest. Each module carries its own version tag, so there is no single @v1.0.0 for submodules.


What a Service Binding Actually Is

When you bind a BTP service instance to your application, the platform injects credentials into the running container. Those credentials typically include:

  • A client ID and secret for authenticating as your application
  • A token service URL: the XSUAA tenant for your subaccount, to which you append /oauth/token to get the token endpoint
  • Service-specific URLs: the Connectivity proxy address, the Destination Service endpoint, the XSUAA base URL

On Cloud Foundry, all bindings for an app arrive in a single environment variable called VCAP_SERVICES. The value is a JSON object whose keys are service types (xsuaa, connectivity, destination) and whose values are arrays of bound instances. An app that has one binding of each looks like this:

{
  "xsuaa": [
    {
      "name": "my-xsuaa",
      "label": "xsuaa",
      "credentials": {
        "clientid": "sb-my-app!t1234",
        "clientsecret": "abc123def456==",
        "url": "https://mysubaccount.authentication.eu10.hana.ondemand.com",
        "xsappname": "my-app!t1234",
        "uaadomain": "authentication.eu10.hana.ondemand.com"
      }
    }
  ],
  "connectivity": [
    {
      "name": "my-connectivity",
      "label": "connectivity",
      "credentials": {
        "clientid": "sb-connectivity!b1",
        "clientsecret": "xyz789==",
        "token_service_url": "https://mysubaccount.authentication.eu10.hana.ondemand.com",
        "onpremise_proxy_host": "connectivity.cf.eu10.hana.ondemand.com",
        "onpremise_socks5_proxy_port": "20004",
        "onpremise_proxy_http_port": "20003"
      }
    }
  ]
}

On Kyma, the platform uses the servicebinding.io spec. Each service binding is a Kubernetes Secret that gets mounted as a directory. By convention, each credential key becomes a file, and the file’s contents are the credential value. A bound XSUAA instance might be mounted at /bindings/my-xsuaa/ with files like type (containing xsuaa), clientid, clientsecret, and url.

The credential values inside those files are exactly what you would find inside the credentials object in VCAP_SERVICES. The difference is purely structural.


binding/cf: Parsing VCAP_SERVICES

The binding/cf sub-package reads VCAP_SERVICES and presents it through the binding.Provider interface:

type Provider interface {
    Binding(serviceType, name string) (map[string]string, error)
}

serviceType is the service label (xsuaa, connectivity, destination). name is the binding instance name — pass an empty string to select the first instance of that type, which is the right choice for most apps that bind exactly one instance of each service.

cf.NewProvider() reads and JSON-decodes VCAP_SERVICES once, when you construct the provider, and returns an error if the variable is unset or malformed. The Binding method looks up the service label, picks the first instance (or the one whose name matches), and flattens its credentials object into a map[string]string. Keys are passed through unchanged, with no renaming. String values are copied as-is, numbers are converted to their decimal string form, and booleans become "true" or "false". Nested objects and arrays are skipped. If the label or instance name is not present, the error wraps binding.ErrNotFound, so you can test for it with errors.Is.


binding/kyma: File-Mounted Secrets

The binding/kyma package implements the servicebinding.io layout. Under a root directory, each binding is a subdirectory, and each credential key is a file in it:

/bindings/my-xsuaa/type            # contains "xsuaa"
/bindings/my-xsuaa/clientid
/bindings/my-xsuaa/clientsecret
/bindings/my-xsuaa/url

Two details matter here.

The type file picks the service, not the directory name. The subdirectory name is an arbitrary label chosen at bind time. When you call Binding("xsuaa", ""), the provider walks the root in lexical order and returns the first directory whose type file equals xsuaa (case-insensitive). If you pass a name, the directory’s name must match it as well.

Older operator versions that write one credentials file. If a binding directory contains a single credentials file holding a JSON object, the provider merges its top-level keys in as fallbacks for any keys that are not present as separate files. Separate files win.

The provider also skips any entry whose name starts with ... Kubernetes projects Secret volumes with those names (..data, timestamped directories) for atomic updates, so skipping them keeps the provider from reading half-swapped state as a binding. Values are whitespace-trimmed. Unlike the CF provider, the Kyma provider does not cache: it reads the directory on each Binding call, so it sees whatever Kubernetes has currently projected.

The provider scans the directory named by the SERVICE_BINDING_ROOT environment variable (the servicebinding.io standard). If that variable is not set, it falls back to /bindings. To point it elsewhere, for example in a test, construct it yourself with kyma.NewProvider(kyma.WithRoot("/path")).


binding/auto: Writing Portable Applications

Most application code should not care whether it runs on CF or Kyma. The binding/auto sub-package detects the runtime environment and returns the right provider:

import (
    "github.com/bluefunda/btp-go/binding/auto"
)

prov, err := auto.NewProvider()
if err != nil {
    log.Fatalf("no service bindings found: %v", err)
}

The detection logic is straightforward, and it runs in this order: if VCAP_SERVICES is set and non-empty, use CF. Otherwise, if SERVICE_BINDING_ROOT is set, use Kyma. Otherwise, if a /bindings directory exists, use Kyma. If none of those hold, return an error. There is no heuristic beyond those checks. Two consequences are worth knowing. Because CF is checked first, a malformed VCAP_SERVICES surfaces as an error from auto.NewProvider() rather than falling through to Kyma. And on the Kyma path, auto only checks that the variable or directory exists. A missing binding shows up later, as a binding.ErrNotFound from the extractor.


Typed Extractors

Once you have a Provider, you could call prov.Binding("xsuaa", "") and manually pick fields from the returned map. But field names vary subtly across service types, and it is easy to make typos. The binding package provides typed extractors that handle this mapping for you:

import (
    "github.com/bluefunda/btp-go/binding"
    "github.com/bluefunda/btp-go/binding/auto"
)

prov, err := auto.NewProvider()
if err != nil {
    log.Fatal(err)
}

// Returns a *binding.XSUAABinding with ClientID, ClientSecret, URL, UAADomain
xsuaaB, err := binding.XSUAA(prov, "")
if err != nil {
    log.Fatal(err)
}

// Returns a *binding.ConnectivityBinding: proxy host and ports plus the
// connectivity service's own OAuth credentials
connB, err := binding.Connectivity(prov, "")
if err != nil {
    log.Fatal(err)
}

// Returns a *binding.DestinationBinding with URI and OAuth credentials
destB, err := binding.Destination(prov, "")
if err != nil {
    log.Fatal(err)
}

The typed structs contain only the fields that btp-go uses downstream:

  • XSUAABinding: ClientID, ClientSecret, URL, UAADomain. There is no token-URL field. The token endpoint is URL + "/oauth/token".
  • DestinationBinding: URI (the Destination Service REST endpoint), ClientID, ClientSecret, TokenServiceURL. If the binding has no token_service_url, TokenServiceURL falls back to the binding’s url.
  • ConnectivityBinding: OnpremiseProxyHost, OnpremiseProxySocks5Port, OnpremiseProxyHTTPPort, ClientID, ClientSecret, TokenServiceURL. The Connectivity service binding carries its own OAuth credentials, which are used to authenticate to the Cloud Connector proxy. The ports are strings, as they arrive in the binding.

The extractors do not validate: a key that is missing from the binding comes back as an empty string. Errors come from the provider (for example binding.ErrNotFound).

The second argument is the instance name — pass an empty string for the first (and usually only) instance.


The xsuaa Module: Token Acquisition

With credentials in hand, the next step is fetching a bearer token. The xsuaa module handles this.

import (
    "time"

    "github.com/bluefunda/btp-go/xsuaa"
)

cfg := xsuaa.Config{
    ClientID:     xsuaaB.ClientID,
    ClientSecret: xsuaaB.ClientSecret,
    TokenURL:     xsuaaB.URL + "/oauth/token",
    // Skew is a time.Duration: how long before the token's expiry to treat it
    // as expired and refresh. Defaults to 60s when zero.
    Skew: 90 * time.Second,
}

ts := xsuaa.NewClientCredentialsSource(cfg)

// Token() fetches or returns a cached token.
token, err := ts.Token(ctx)
if err != nil {
    log.Fatal(err)
}

The Token method returns the raw JWT string. Under the hood it issues a POST to the token URL with grant_type=client_credentials and HTTP Basic auth (ClientID:ClientSecret). The body is application/x-www-form-urlencoded. The response is a standard OAuth2 JSON payload:

{
  "access_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 3600,
  "scope": "..."
}

Only access_token and expires_in are read. A non-200 response, a malformed body, or a response with an empty access_token comes back as an error.

Why Not golang.org/x/oauth2?

The obvious question is why btp-go implements its own token client instead of using golang.org/x/oauth2, which already handles client credentials flows.

The short answer is that golang.org/x/oauth2 is not part of the Go standard library, and the binding, xsuaa, connectivity, and destination modules are deliberately standard-library-only. Of the six btp-go modules, sshclient is the one that takes an external dependency (golang.org/x/crypto/ssh). httpclient imports the other btp-go modules.

The practical answer is that golang.org/x/oauth2 brings its own opinions about token sources and HTTP clients that don’t compose cleanly with the rest of btp-go. Posting a form and parsing a JSON response with net/http is not much code, and it means the xsuaa module has exactly one job and no dependency beyond the Go standard library.


Token Caching and the Singleflight Problem

Caching tokens is straightforward: store the token and its expiry, check before fetching. The tricky part is what happens when the token expires and multiple goroutines call Token(ctx) simultaneously. Without coordination, every goroutine will see a stale token, make a request to the token endpoint, and you get a thundering herd: ten concurrent requests to XSUAA where one would have sufficed.

The classic solution is golang.org/x/sync/singleflight, a small package that ensures only one in-flight call runs at a time and all concurrent waiters share the result. But importing golang.org/x/sync just for this would introduce an external dependency in the core xsuaa module.

Instead, xsuaa implements the same pattern directly using a sync.Mutex, an inflight flag, and a list of waiter channels:

type clientCredentialsSource struct {
    cfg Config

    mu       sync.Mutex
    cached   *tokenEntry // value + expiry
    inflight bool
    waiters  []chan struct{}
    // outcome of the most recent fetch, shared with waiters
    result tokenEntry
    err    error
}

On each Token(ctx) call, a goroutine takes the mutex and checks the cache. If the cached token is still valid, it returns it. If not, and inflight is already set, it registers a channel in waiters, releases the mutex, and blocks until that channel is closed or its own context is cancelled. Otherwise it becomes the leader: it sets inflight, releases the mutex, makes the HTTP call, stores the outcome, and closes every waiter’s channel. All waiters then return the leader’s result.

The expiry stored with the token is the time of the response plus expires_in, minus Skew. That is why a token is refreshed slightly early rather than at the last second. If a refresh fails, the cache is cleared, every waiter in that round gets the same error, and the next call to Token tries again. The package’s tests include one that checks concurrent callers coalesce into a single request.


Putting It Together: A Portable Bootstrap

Here is a complete, runnable bootstrap that works identically on Cloud Foundry and Kyma:

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/bluefunda/btp-go/binding"
    "github.com/bluefunda/btp-go/binding/auto"
    "github.com/bluefunda/btp-go/xsuaa"
)

func main() {
    ctx := context.Background()

    // Step 1: Detect the runtime environment and load service bindings.
    prov, err := auto.NewProvider()
    if err != nil {
        log.Fatalf("service binding detection failed: %v", err)
    }

    // Step 2: Extract typed XSUAA credentials.
    xsuaaB, err := binding.XSUAA(prov, "")
    if err != nil {
        log.Fatalf("XSUAA binding not found: %v", err)
    }

    // Step 3: Create a token source. This does not fetch a token yet.
    // The token endpoint is the binding's base URL plus /oauth/token.
    // Skew is left at its 60-second default.
    ts := xsuaa.NewClientCredentialsSource(xsuaa.Config{
        ClientID:     xsuaaB.ClientID,
        ClientSecret: xsuaaB.ClientSecret,
        TokenURL:     xsuaaB.URL + "/oauth/token",
    })

    // Step 4: Fetch the first token. Subsequent calls return the cached token
    // until 60 seconds before expiry, at which point the source refreshes.
    token, err := ts.Token(ctx)
    if err != nil {
        log.Fatalf("token request failed: %v", err)
    }

    fmt.Printf("token acquired, length=%d\n", len(token))
}

On Cloud Foundry, auto.NewProvider() reads VCAP_SERVICES. On Kyma, it reads SERVICE_BINDING_ROOT, or /bindings if that is unset. The rest of the code is identical.

This token source is for the XSUAA binding only. Each BTP service binding carries its own client credentials, so the Destination and Connectivity services each need their own token source, built from db.TokenServiceURL + "/oauth/token" and cb.TokenServiceURL + "/oauth/token" with that binding’s ClientID and ClientSecret. Part 3 shows this wiring.

The token source ts implements the xsuaa.TokenSource interface (Token(ctx context.Context) (string, error)). The connectivity, destination, and httpclient modules each define their own structurally identical local interface. Because Go uses structural typing, you can pass a token source directly to any of them without an adapter — the compiler verifies the method signature at compile time.


What’s Next

Part 2 covers the foundation: getting credentials and getting a token. Part 3 moves to the interesting problem — actually reaching an on-prem system.

SAP Cloud Connector acts as the reverse proxy between BTP and your corporate network. It exposes a SOCKS5-like interface, but with a non-standard authentication method (the 0x80 byte) that standard SOCKS5 libraries do not speak. Part 3 walks through how btp-go’s connectivity module handles this, how the Destination Service maps logical names to real host/port/auth combinations, and how to wire the two together.

Next up — Part 3: Reaching On-Prem: Connectivity and Destination.

Share this article