Token Binding (SDK Layer)#

To bind authorization codes and refresh tokens to a DPoP key pair, configure token binding on HaapiConfiguration. The SDK Layer threads the binding through both the HAAPI flow and the OAuth token exchange automatically. For the concept and security trade-offs, see Token Binding.

Configuration#

let tokenBoundConfiguration = BoundedTokenConfiguration(
    keyPairType: CryptoKeyType.secureEnclave
)

let haapiConfiguration = HaapiConfiguration(
    // ...other configuration
    tokenBoundConfiguration: tokenBoundConfiguration
)

keyPairType — where the DPoP key pair lives:

  • CryptoKeyType.secureEnclave — hardware-backed. The key is generated and held inside the Secure Enclave; the private key never leaves the enclave and cannot be extracted even with root access. Preferred when available.
  • CryptoKeyType.p256 — software-backed P256 key pair stored in the Keychain. Use as a fallback when the Secure Enclave is unavailable: App Extensions cannot access the enclave, some device states temporarily lock it, and simulators have no enclave. Less hardware-resistant but more reliable across edge cases.

BoundedTokenConfiguration vs UnboundedTokenConfiguration. The framework exposes both an explicit “bind tokens” and an explicit “don’t bind tokens” configuration. Omit tokenBoundConfiguration to default to unbounded; pass UnboundedTokenConfiguration() to be explicit. The explicit form makes intent visible in code review and prevents accidental binding when the server changes its setting.

Recovering from key-creation failures. If Secure Enclave rejects key generation (rare; documented as HaapiError.dpopKeyCreationFailure and dpopProofFailure), the recommended recovery is to reconstruct the configuration with keyPairType: .p256 and re-authenticate. See Error Handling (SDK Layer).

val haapiConfiguration = HaapiConfiguration(
    // ...other configuration
    dpopTokenBinding = DpopTokenBindingConfiguration.Builder(context).build()
)

// To choose the key-protection mode or storage explicitly:
val binding = DpopTokenBindingConfiguration.Builder(context) {
    keyType = DpopKeyPolicy.Hardware           // Auto (default), Hardware, or Software
    storage = StorageBuilder.shared(context) // encrypted(context) is the default
}.build()

DpopTokenBindingConfiguration.Builder(context) takes an application Context and defaults to a hardware-backed key (DpopKeyPolicy.Auto) and an encrypted store, so the minimal form is a one-liner — no custom Storage required. Assign the result to HaapiConfiguration.dpopTokenBinding (a dedicated constructor overload), or set it through the SDK/widget builder.

DpopKeyPolicy — Auto (default; hardware-backed with a transparent software fallback), Hardware (fails closed with a typed error when no usable Keystore is present), or Software (encrypted software key). The protection actually in use is observable through HaapiTokenManager.dpopKeyInUse (Hardware, Software, or None), so an Auto fallback is never silent.

StorageBuilder — encrypted(context) (AES-GCM at rest under a non-exportable Keystore key; the default) or shared(context) (encrypted and readable across the app’s processes/components). The builder also carries the signing algorithm (ES256 by default) and the DPoP iat time source. Its Keystore key is provisioned on first construction, serialized across the app’s processes with a file lock so constructing the store from several processes at once still yields a single shared key.

Legacy path. The TokenBoundConfiguration type remains for backward compatibility but is deprecated in favor of DpopTokenBindingConfiguration.Builder; the two are mutually exclusive and supplying both is rejected immediately. Switching an established session to the builder needs no re-authentication: a legacy key stored under the manager’s key-store alias is adopted in place automatically, and a custom legacy alias is adopted by setting migrateFromLegacyAlias on the builder.

Opting out. Omit both dpopTokenBinding and TokenBoundConfiguration (or set them to null) to disable binding.

Token binding is configured per platform as a record attached to createIOSConfiguration / createAndroidConfiguration. The native bridge generates and manages the DPoP key pair internally — your JS code does not handle keys, Keychain entries, or Keystore aliases directly.

import {
    createIOSConfiguration,
    createAndroidConfiguration,
    BoundTokenConfiguration,
    CryptoKeyType,
    KeyPairAlgorithmConfig,
    StorageOption,
    CurrentTimeMillisProviderOption,
} from 'identityserver.haapi.reactnative.sdk'

const iosConfig = createIOSConfiguration({
    clientId: 'haapi-ios-client',
    appRedirect: 'app://haapi',
    tokenBoundConfiguration: BoundTokenConfiguration({
        keyPairType: CryptoKeyType.SecureEnclave, // or CryptoKeyType.P256 fallback
    }),
})

const androidConfig = createAndroidConfiguration({
    clientId: 'haapi-android-client',
    appRedirect: 'app://haapi',
    tokenBoundConfiguration: {
        keyAlias: 'token-bound-key',
        keyPairAlgorithmConfig: KeyPairAlgorithmConfig.ES256,
        storageOption: StorageOption.EncryptedSharedPreferences,
        currentTimeMillisProviderOption: CurrentTimeMillisProviderOption.System,
    },
})

iOS — BoundTokenConfiguration carries the keyPairType (SecureEnclave or P256). Use UnboundedTokenConfiguration() to be explicit about disabling binding, or omit the field. The DPoP key is generated and held in the native Secure Enclave / Keychain — never crosses the bridge.

Android — AndroidTokenBoundConfiguration mirrors the native record but resolves the storage and currentTimeMillisProvider implementations through StorageOption and CurrentTimeMillisProviderOption enums. The SDK ships a SharedPreferences-backed Storage for StorageOption.Default and a System.currentTimeMillis() clock for CurrentTimeMillisProviderOption.Default. For any other value (e.g., EncryptedSharedPreferences, DataStore, a deterministic test clock), the host app must register a Resolver<RNStorage> / Resolver<CurrentTimeMillisProvider> in Kotlin — see Native Resolvers. Omit the field to disable binding entirely.

Opting out. Omit tokenBoundConfiguration on either platform config to default to unbounded.

Choosing a Configuration#

The client binding configuration must match the server’s issue-token-bound-authorization-code setting. Mismatches produce specific errors:

Server issue-token-bound-authorization-codeClient configurationResult
trueBound (BoundedTokenConfiguration iOS / dpopTokenBinding Android)✅ Refresh token bound to the DPoP key
trueNo binding❌ Server returns invalid_dpop_proof on token exchange
falseBound✅ Refresh token bound on the client side anyway
falseNo binding✅ Refresh token not bound

When binding is enabled on either side, the OAuth token exchange must include the active DPoP proof — see “Exchanging the Code” below.

Exchanging the Code#

When binding is enabled, the OAuth token exchange must include the DPoP proof. On iOS this is explicit: pass haapiManager.dpop into OAuthTokenManager.fetchAccessToken. On Android the SDK threads the DPoP material through automatically when token binding is configured (dpopTokenBinding, or the legacy TokenBoundConfiguration).

// Provide haapiManager.dpop when binding is enabled.
let dPoP = haapiManager.dpop

oAuthTokenManager.fetchAccessToken(with: code, dpop: dPoP) { [weak self] tokenResponse in
    self?.handleTokenResponse(tokenResponse)
}
// On Android, token binding is configured via the HaapiConfiguration's
// dpopTokenBinding (or the legacy TokenBoundConfiguration); the SDK threads
// the DPoP material through fetchAccessToken automatically when binding is enabled.
val tokenResponse = oAuthTokenManager.fetchAccessToken(
    authorizationCode = code,
    onCoroutineContext = this.coroutineContext,
    additionalParameters = emptyMap()
)
// On React Native, token binding is configured via the platform config records
// (IOSTokenBoundConfiguration / AndroidTokenBoundConfiguration). The native side
// threads the DPoP material through automatically; the JS call signature is the
// same whether binding is enabled or not.
const tokenResponse = await accessor.oauthTokenManager.fetchAccessToken(code)

The DPoP key never crosses the bridge — neither do nonces, proofs, or HTTP-level binding details. Enable binding by attaching the configuration record (see “Configuration” above) and the native iOS / Android layer handles the rest.

For the full OAuth lifecycle (fetch, refresh, revoke) — and for reading the bound key back out to call a resource server, dpop(forAccessToken:) on iOS and dpopHelper(accessToken) on Android — see OAuthTokenManager.

Omitting the DPoP proof on the token exchange when binding is enabled yields a server error invalid_dpop_proof. iOS callers must pass haapiManager.dpop explicitly; Android does this automatically when dpopTokenBinding (or the legacy TokenBoundConfiguration) is configured.

Was this helpful?