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 asinvalid_dpop_proofat 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(Autois the recommended default — hardware-backed with a transparent software fallback; orHardware/Software) and aStorageBuilder(encrypted(context)by default, orshared(context)for cross-process access). No customStorageimplementation 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 (
CryptoKeyTypefor iOS;DpopKeyPolicy,KeyPairAlgorithmConfigandStorageOptionfor Android) and pass them toiOSTokenBinding()/AndroidTokenBinding()astokenBindingConfiguration. 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-tokenson, sign one per request withoauthTokenManager.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_typedescribes the access token, not the refresh token. It isbearerunless 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 isDPoP. - The token exchange succeeds. If the server has
issue-token-bound-authorization-code = trueand the client omitted the binding, the exchange fails withinvalid_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 = trueon the server but omitting the client-side binding returnsinvalid_dpop_proofat 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/dpopProofFailureand reconstruct withkeyPairType: .p256for the fallback path. - Android —
Hardwarefails closed.DpopKeyPolicy.Hardwarethrows a typed error on devices without a usable hardware Keystore (in React Native,HaapiErrorCode.DpopKeyCreationFailure). UseDpopKeyPolicy.Auto(the default) for a transparent software fallback, and readHaapiTokenManager.dpopKeyInUseto confirm which protection (Hardware,Software, orNone) is actually active (native only). Storage needs no custom implementation —StorageBuilder.encrypted(context)(the default) is the production choice; useStorageBuilder.shared(context)when a separate process or app widget must read the same session. - iOS App Extension binding. Use the
.p256software key on iOS App Extensions —DCAppAttestService(and thus the enclave-binding path) is unavailable in extension contexts.