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; DpopKeyPolicy, KeyPairAlgorithmConfig and StorageOption for Android) and pass them to iOSTokenBinding() / AndroidTokenBinding() as tokenBindingConfiguration. Binding is off unless you do.

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. Token binding is off by default; turn it on per platform with tokenBindingConfiguration:

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

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

const androidConfig = createAndroidConfiguration({
    clientId: 'haapi-android-client',
    appRedirect: 'com.example.app://haapi',
    tokenBindingConfiguration: AndroidTokenBinding(), // DpopKeyPolicy.Auto, ES256, encrypted default storage
})

An app upgrading from a React Native SDK version that bound tokens by default keeps its users signed in by adopting their key: AndroidTokenBinding({ migrateFromLegacyAlias: 'haapi_token_bound_key' }) — see the React Native upgrade guide.

The DPoP key never crosses the bridge — the native iOS Secure Enclave / Keychain or Android Keystore holds it. JS code does not manage keys, and the token exchange’s nonces and proofs stay native. On Android the default StorageOption.Default keeps the stored binding state encrypted at rest; there is nothing to configure. To turn binding off again, omit the setting or pass noTokenBinding().

DPoP-bound access tokens need a proof on every call to your own API. With issue-dpop-bound-access-tokens on, sign one per request with oauthTokenManager.createDpopProof — see OAuthTokenManager. Refresh-token binding needs no extra setting.

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:

  • The refresh token is bound to the DPoP key: a refresh signed with a different key fails with invalid_grant.
  • The response’s token_type describes the access token, not the refresh token. It is bearer unless the client is also configured to issue DPoP-bound access tokens (issue-dpop-bound-access-tokens, available from Curity Identity Server 11.5), in which case it is DPoP.
  • 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 (in React Native, HaapiErrorCode.DpopKeyCreationFailure). 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 (native only). 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?