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.

Default: no token binding on both platforms. Enable it per platform with tokenBindingConfiguration when your client issues token-bound authorization codes (issue-token-bound-authorization-code). The native bridge generates and manages the DPoP key pair — your JS code never handles keys, Keychain entries, or Keystore aliases.

import {
    createIOSConfiguration,
    createAndroidConfiguration,
    iOSTokenBinding,
    AndroidTokenBinding,
    CryptoKeyType,
    DpopKeyPolicy,
    KeyPairAlgorithmConfig,
    StorageOption,
} from '@curity/identityserver-haapi-react-native-sdk'

const iosConfig = createIOSConfiguration({
    clientId: 'haapi-ios-client',
    appRedirect: 'com.example.app://haapi',
    tokenBindingConfiguration: iOSTokenBinding(), // Secure Enclave by default; { keyPairType: CryptoKeyType.P256 } on a simulator
})

const androidConfig = createAndroidConfiguration({
    clientId: 'haapi-android-client',
    appRedirect: 'com.example.app://haapi',
    tokenBindingConfiguration: AndroidTokenBinding({
        keyType: DpopKeyPolicy.Auto, // the default: hardware-backed, with a software fallback
        keyPairAlgorithmConfiguration: KeyPairAlgorithmConfig.ES256, // the default
        storageOption: StorageOption.Default, // the default: encrypted at rest under a non-exportable Keystore key
    }),
})

Turning it off. Omit tokenBindingConfiguration or pass noTokenBinding() — the same on both platforms.

iOS — iOSTokenBinding() takes the keyPairType (SecureEnclave by default, or P256 / Security). The DPoP key is generated and held in the native Secure Enclave / Keychain — it never crosses the bridge.

Android — AndroidTokenBinding() takes the key policy (keyType: DpopKeyPolicy.Auto by default, Hardware or Software), the algorithm, the Keystore alias of an earlier version’s key to adopt (migrateFromLegacyAlias, none by default) and a StorageOption — the same settings as the native DpopTokenBindingConfiguration.Builder. As on native, keyType applies only while attestation is off — including when the SDK falls back to dynamic client registration on a device that can’t attest — Hardware fails sign-in with HaapiErrorCode.DpopKeyCreationFailure on a device that can’t create a hardware-backed key, and new keys are named after the configuration’s keyStoreAlias, so keep that alias stable. React Native has no counterpart to dpopKeyInUse. The DPoP iat clock is chosen on the Android configuration itself with currentTimeMillisProviderOption. The default storage, StorageOption.Default, keeps values encrypted at rest under a non-exportable Android Keystore key, and values stored by earlier SDK versions move into it automatically — see Native Resolvers. For your own store (DataStore, an in-memory store for tests) or clock, select StorageOption.Custom / CurrentTimeMillisProviderOption.Custom and register a Resolver<RNStorage> / Resolver<CurrentTimeMillisProvider> in Kotlin.

A tokenBindingConfiguration value that isn’t built by these factories, or is missing a field, fails initialization with HaapiErrorCode.InvalidConfiguration — it is never treated as “no binding”.

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 set with tokenBindingConfiguration on each
// platform configuration. The native side threads the DPoP material through
// automatically; the JS call signature is the same whether binding is on or not.
const tokenResponse = await accessor.oauthTokenManager.fetchAccessToken(code)

The DPoP key never crosses the bridge, and for the token exchange neither do nonces, proofs, or HTTP-level binding details. With binding on (see “Configuration” above), the proof is attached for you on both platforms; with it off, none is sent. If the server refuses the exchange (for example, a client that requires token-bound codes while binding is off), the call resolves with a token error response carrying the server’s error.

For the full OAuth lifecycle (fetch, refresh, revoke) — and for signing with the bound key to call a resource server, dpop(forAccessToken:) on iOS, dpopHelper(accessToken) on Android and createDpopProof on React Native — 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?