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.
Overview
Section titled “Overview”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:
| Call | When | What it does |
|---|---|---|
enrollForMfa | once per user, per device, after login | generates the hardware-backed signing key and registers its public key and attestation chain. No biometric prompt appears here. |
setMfaPin | when the user sets or changes a PIN | the PIN is verified and hashed on the server. It does not unlock the device signing key, which stays silent. |
signAction | per transaction | signs the action and returns the token your backend verifies |
What the token proves
Section titled “What the token proves”Three things, together, and the third is what device attestation alone cannot give you:
- the device was in a state Alphyn had attested at the moment of signing;
- 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”;
- the signature came from a key that exists only on this user’s device, created by
enrollForMfaand never exportable.
Your backend validates the token server-side and compares the echoed context against the transaction in front of it. See Backend verification.
Platform support
Section titled “Platform support”Enrol, PIN, signing and subject identity are available on every platform Alphyn supports.
| Android | iOS | |
|---|---|---|
| Native | yes | yes |
| React Native | yes | yes |
| Flutter | yes | yes |
| Unity | yes | yes |
The lifecycle, in order
Section titled “The lifecycle, in order”1. Enrol, once, after login
Section titled “1. Enrol, once, after login”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.
2. Set a PIN, if your flow has one
Section titled “2. Set a PIN, if your flow has one”setMfaPin(userId, newPin) // first timesetMfaPin(userId, newPin, oldPin) // changing itThe 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.
3. Sign, per transaction
Section titled “3. Sign, per transaction”signAction(userId, actionLabel, context, pin, subject)actionLabelis your own name for the operation:TRANSFER,WITHDRAW,LOGIN.contextis 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”.pinis optional.subjectis 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.
What the result carries
Section titled “What the result carries”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:
| Field | Meaning |
|---|---|
success | whether the action was signed |
verificationToken | the JWT your backend verifies. Send it whole; it is not a display value |
expiresAtMs | epoch milliseconds after which the token is no longer valid |
errorCode | the backend’s code verbatim, for example THREAT_DETECTED, USER_NOT_ENROLLED |
errorMessage | a 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.
Errors you will actually meet
Section titled “Errors you will actually meet”| Code | What happened | What to do |
|---|---|---|
NOT_INITIALIZED | signAction or enrollForMfa ran before the SDK was ready | call it from a user-triggered action, not at launch |
USER_NOT_ENROLLED | no signing key for this (user, device) | run enrollForMfa and retry |
WRONG_OLD_PIN | the PIN change did not match, or a first-time set for a user who already has one | check isPinReady before deciding which |
THREAT_DETECTED | the device was under an active threat, so nothing was signed | do not retry silently; this is a refusal, not a transport failure |
NOT_SUPPORTED | the running SDK does not expose this call | update the SDK |