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
keyPairAlgorithmConfig(ES256is the recommended default), pick a stablekeyAlias(e.g.,"token-bound-key"), and supply aStorageimplementation (SharedPreferences,EncryptedSharedPreferences, or a custom-keystore-backed store). - React Native — pick the same parameters per platform (
CryptoKeyTypefor iOS,KeyPairAlgorithmConfig+StorageOptionfor 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 tokenBoundConfiguration = TokenBoundConfiguration(
keyAlias = "token-bound-key",
keyPairAlgorithmConfig = KeyPairAlgorithmConfig.ES256,
storage = mySharedPrefsStorage,
currentTimeMillisProvider = { System.currentTimeMillis() }
)
val haapiConfiguration = HaapiConfiguration(
// ...other configuration
tokenBoundConfiguration = tokenBoundConfiguration
)UI Layer#
WidgetConfiguration.Builder(...)
.setTokenBoundConfiguration(
TokenBoundConfiguration(
keyAlias = "token-bound-key",
keyPairAlgorithmConfig = KeyPairAlgorithmConfig.ES256,
storage = mySharedPrefsStorage,
currentTimeMillisProvider = { System.currentTimeMillis() }
)
)
.build() 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 tokenBoundConfiguration is set.
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 = 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 omittingtokenBoundConfigurationon the client 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
Storageis mandatory. Omitting it causes the SDK to lose its key-pair metadata across OS upgrades or biometric resets.EncryptedSharedPreferencesis the recommended production choice; plainSharedPreferencesworks for development. - iOS App Extension binding. Use the
.p256software key on iOS App Extensions —DCAppAttestService(and thus the enclave-binding path) is unavailable in extension contexts.