How to Configure Token Binding#

You want to make a refresh-token theft useless without the corresponding private key on the original device. Token binding pins each authorization code and refresh token to a DPoP key pair on the device, so a token lifted from one client cannot be replayed from another.

For the concept and trade-offs, see Token Binding. For per-parameter depth, see Token Binding (SDK Layer). This page is the end-to-end task walkthrough.

Prerequisites#

  • A working HAAPI integration (per Quickstart — iOS or Quickstart — Android).
  • On the Curity Identity Server, the OAuth profile has issue-token-bound-authorization-code = true. The client and server token-binding settings must match; mismatches surface as invalid_dpop_proof at the token-exchange step.

Step-by-Step#

1. Pick the binding parameters#

  • iOS — choose keyPairType: .secureEnclave (hardware-backed; preferred) or .p256 (software-backed, App-Extension-safe fallback).
  • Android — choose a DpopKeyPolicy (Auto is the recommended default — hardware-backed with a transparent software fallback; or Hardware / Software) and a StorageBuilder (encrypted(context) by default, or shared(context) for cross-process access). No custom Storage implementation is required; if you supply one anyway, it must encrypt and integrity-protect values at rest, as the built-in stores do — stored values include serialized key material.
  • React Native — pick the same parameters per platform (CryptoKeyType for iOS, KeyPairAlgorithmConfig + StorageOption for Android) and pass them in the platform config records.

2. Wire it onto your builder#

The same binding type works from either layer — wire it at the layer where your app’s configuration lives.

SDK Layer#

let tokenBoundConfiguration = BoundedTokenConfiguration(
    keyPairType: CryptoKeyType.secureEnclave
)

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

UI Layer#

HaapiUIKitConfigurationBuilder(...)
    .setTokenBoundConfiguration(
        configuration: BoundedTokenConfiguration(
            keyPairType: CryptoKeyType.secureEnclave
        )
    )
    .build()

SDK Layer#

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

UI Layer#

WidgetConfiguration.Builder(...)
    .setDpopTokenBinding(
        DpopTokenBindingConfiguration.Builder(context).build()
    )
    .build()

DpopTokenBindingConfiguration.Builder(context).build() defaults to a hardware-backed key (DpopKeyPolicy.Auto) and an encrypted store, so no custom Storage is required. Set keyType or storage in the builder lambda to choose explicitly — see Token Binding (SDK Layer). The legacy setTokenBoundConfiguration(...) / TokenBoundConfiguration path is still available but deprecated, and mutually exclusive with the builder above.

React Native has only an SDK Layer surface. Attach tokenBoundConfiguration to each platform config record:

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 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,
    },
})

The DPoP key never crosses the bridge — the native iOS Secure Enclave / Keychain or Android Keystore holds it. JS code does not manage keys, nonces, or proofs.

3. (iOS, SDK Layer only) Pass the DPoP proof on token exchange#

At the SDK Layer on iOS, the token-exchange call is explicit — pass haapiManager.dpop to OAuthTokenManager.fetchAccessToken:

oAuthTokenManager.fetchAccessToken(with: code, dpop: haapiManager.dpop) { tokenResponse in
    // handle the response
}

On Android (SDK Layer) and at the UI Layer on either platform, the SDK threads the DPoP material through automatically once token binding is configured (dpopTokenBinding, or the legacy tokenBoundConfiguration).

Verify It Works#

Inspect the refresh token returned by a successful flow:

  • A bound refresh token carries "token_type": "DPoP" in the token-endpoint response.
  • The token exchange succeeds. If the server has issue-token-bound-authorization-code = true and the client omitted the binding, the exchange fails with invalid_dpop_proof.

For the full server/client matrix and what each combination produces, see the table in Token Binding (SDK Layer).

Pitfalls#

  • Server-client mismatch. Setting issue-token-bound-authorization-code = true on the server but omitting the client-side binding returns invalid_dpop_proof at token exchange — silently, after the user authenticates. Configure both sides together.
  • Secure Enclave unavailable. App Extensions, simulators, and some device-locked states cannot access the Secure Enclave. Catch HaapiError.dpopKeyCreationFailure / dpopProofFailure and reconstruct with keyPairType: .p256 for the fallback path.
  • Android — Hardware fails closed. DpopKeyPolicy.Hardware throws a typed error on devices without a usable hardware Keystore. Use DpopKeyPolicy.Auto (the default) for a transparent software fallback, and read HaapiTokenManager.dpopKeyInUse to confirm which protection (Hardware, Software, or None) is actually active. Storage needs no custom implementation — StorageBuilder.encrypted(context) (the default) is the production choice; use StorageBuilder.shared(context) when a separate process or app widget must read the same session.
  • iOS App Extension binding. Use the .p256 software key on iOS App Extensions — DCAppAttestService (and thus the enclave-binding path) is unavailable in extension contexts.

Was this helpful?