Skip to content

MFA Transaction Signing

Sign a transaction with a hardware-backed key that belongs to one user on one device, and hand your backend a token it can verify server-side before it moves any money.

MFA transaction signing answers a question device attestation cannot: who authorised this action, on a key only this person’s device holds. The SDK generates a signing key inside the Secure Enclave or the Android hardware keystore, registers its public key and attestation chain with the Alphyn backend, and produces a short-lived token per transaction that is bound to the exact context you signed.

Three calls, in this order:

CallWhenWhat it does
enrollForMfaonce per user, per device, after logingenerates the hardware-backed signing key and registers its public key and attestation chain. No biometric prompt appears here.
setMfaPinwhen the user sets or changes a PINthe PIN is verified and hashed on the server. It does not unlock the device signing key, which stays silent.
signActionper transactionsigns the action and returns the token your backend verifies

Three things, together, and the third is what device attestation alone cannot give you:

  1. the device was in a state Alphyn had attested at the moment of signing;
  2. the transaction context was signed as-is: the hash is bound into the signature, so the token proves these values and not merely “a transfer”;
  3. the signature came from a key that exists only on this user’s device, created by enrollForMfa and never exportable.

Your backend validates the token server-side and compares the echoed context against the transaction in front of it. See Backend verification.

Enrol, PIN, signing and subject identity are available on every platform Alphyn supports.

AndroidiOS
Nativeyesyes
React Nativeyesyes
Flutteryesyes
Unityyesyes
enrollForMfa(userId)

userId is your identifier for the logged-in user, whatever your backend already calls them. It is not created by Alphyn and it is not a phone number or an e-mail address.

Enrolment does network I/O and it is idempotent per (user, device). Drive it from a user-triggered moment rather than straight after initialize(): called too early it answers NOT_INITIALIZED.

On success the result carries securityLevel (where the key actually ended up, for example STRONGBOX or TRUSTED_ENVIRONMENT on Android) and enrollmentId, the backend’s identifier for this enrolment.

setMfaPin(userId, newPin) // first time
setMfaPin(userId, newPin, oldPin) // changing it

The PIN is per user, not per device, and it is verified and hashed on the server. A first-time set for a user who already has a PIN from another device answers WRONG_OLD_PIN; check isPinReady on the result rather than guessing which case you are in.

A PIN is not a prerequisite for signAction. Omitting it omits that factor rather than failing the call.

Pass the PIN as bytes or as a character array wherever the platform allows one, never as a string you keep. Every SDK zeroes what it is given before the call returns; a string you built yourself cannot be wiped.

signAction(userId, actionLabel, context, pin, subject)
  • actionLabel is your own name for the operation: TRANSFER, WITHDRAW, LOGIN.
  • context is the transaction itself: amount, currency, destination. Its hash is bound into the signature, so the token proves the user signed these values and not merely “a transfer”.
  • pin is optional.
  • subject is optional and declares whose transaction this is. See Subject identity.

Call it after the user has cleared your confirmation screen: the nonce behind the signature has a 60 second time to live. signAction aborts without signing if the device is under an active threat, which is the point of doing it here rather than on your server.

signAction returns a verification token and an expiry. That token is what your backend validates: see Backend verification. Everything else on the result is diagnostic:

FieldMeaning
successwhether the action was signed
verificationTokenthe JWT your backend verifies. Send it whole; it is not a display value
expiresAtMsepoch milliseconds after which the token is no longer valid
errorCodethe backend’s code verbatim, for example THREAT_DETECTED, USER_NOT_ENROLLED
errorMessagea human-readable message; do not branch on it

Branch on errorCode, never on errorMessage. Codes are stable and are passed through verbatim, so a code your version does not recognise still reaches you rather than being collapsed onto an enum.

CodeWhat happenedWhat to do
NOT_INITIALIZEDsignAction or enrollForMfa ran before the SDK was readycall it from a user-triggered action, not at launch
USER_NOT_ENROLLEDno signing key for this (user, device)run enrollForMfa and retry
WRONG_OLD_PINthe PIN change did not match, or a first-time set for a user who already has onecheck isPinReady before deciding which
THREAT_DETECTEDthe device was under an active threat, so nothing was signeddo not retry silently; this is a refusal, not a transport failure
NOT_SUPPORTEDthe running SDK does not expose this callupdate the SDK