Action Signing
Action signing lets you use a passkey as a cryptographic signature for a specific user action — a fund transfer, a permission change, a destructive operation. The user taps Face ID or Touch ID (or inserts a hardware key), and you get unforgeable proof that this exact user, on this exact device, approved this exact operation.
This is different from authentication (signIn). Authentication proves who the user is when they arrive. Action signing proves they explicitly approved a specific action mid-session — even if the session is already established.
When to use it
| Use case | Why action signing fits |
|---|---|
| Wire transfers, crypto withdrawals | Prevents session hijacking from causing financial loss |
| Granting admin/owner permissions | Non-repudiable audit trail — user cannot claim they didn’t approve |
| Deleting an account or organization | Extra friction for irreversible operations |
| Changing MFA / recovery settings | Prevents an attacker with a valid session from locking out the user |
| High-value API key rotation | Ties key issuance to a biometric gesture on a registered device |
How it works
Browser TryMellon API
│ │
│── POST /v1/actions/challenges ────>│ issue challenge (payload hash + nonce)
│<── { challenge_id, webauthn_options }
│
│ [browser WebAuthn prompt — user taps biometric]
│
│── POST /v1/actions/verify ────────>│ verify signature + consume challenge (atomic)
│<── { verified: true }
- The SDK calls
POST /v1/actions/challengeswith yourpayloadhash. - TryMellon returns a WebAuthn
publicKeyCredentialRequestOptionschallenge tied to that payload. - The browser prompts the user (Face ID, Touch ID, Windows Hello, hardware key).
- The signed assertion is sent to
POST /v1/actions/verify. TryMellon checks the signature, validates the payload hash, and marks the challenge as consumed. - Each challenge is single-use — replaying the same
challenge_idreturnsACTION_ALREADY_CLAIMED.
Quick start
import { TryMellon } from '@trymellon/js'
const clientResult = TryMellon.create({
appId: 'YOUR_APP_ID',
publishableKey: 'cli_...',
})
if (!clientResult.ok) throw clientResult.error
const client = clientResult.value
// Prompt the user to approve an action with their passkey
const result = await client.action.sign({
userId: 'user_123',
payload: {
action: 'transfer',
amount: 5000,
currency: 'USD',
to: 'account_456',
},
})
if (!result.ok) {
switch (result.error.code) {
case 'USER_CANCELLED':
showMessage('Approval cancelled. The transfer was not processed.')
break
case 'ACTION_CHALLENGE_EXPIRED':
showMessage('The approval window expired. Please try again.')
break
default:
showMessage('Could not verify your identity. Please try again.')
}
return
}
// Forward the signed result to your backend
await fetch('/api/transfer', {
method: 'POST',
body: JSON.stringify({
...transferData,
actionSignature: result.value.signature,
challengeId: result.value.challengeId,
}),
})
Backend verification
Your backend calls POST /v1/actions/verify before executing the action. Do not execute the action and then verify — the verification must gate the operation.
POST /v1/actions/verify
Authorization: Bearer <session_token>
Content-Type: application/json
{
"challenge_id": "uuid",
"authentication_response": { /* WebAuthn assertion — passed through from SDK result */ },
"payload_hash": "sha256-hex-of-your-payload",
"rp_id": "your-app.com"
}
Response — success:
{
"ok": true,
"data": {
"verified": true,
"userId": "uuid",
"externalUserId": "user_123",
"challengeId": "uuid",
"verifiedAt": "2026-04-08T12:00:00Z"
}
}
Response — challenge already used:
{
"ok": false,
"error": { "code": "action_already_claimed", "message": "Challenge already verified" }
}
Payload hash
Hash your action payload before sending to avoid binding to a mutable string. Use SHA-256:
// Node.js
import { createHash } from 'node:crypto'
const payloadHash = createHash('sha256')
.update(JSON.stringify(payload))
.digest('hex')
// Browser (via SubtleCrypto)
const encoded = new TextEncoder().encode(JSON.stringify(payload))
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded)
const payloadHash = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
The SDK computes this internally from the payload object you pass to client.action.sign(). Your backend must compute the same hash from the same canonical JSON to verify ACTION_PAYLOAD_MISMATCH doesn’t fire due to key-order differences. Use JSON.stringify with sorted keys or a canonical JSON library on both sides.
Challenge TTL and expiry
Challenges expire after 5 minutes by default. If the user takes longer to complete biometric authentication (e.g. hardware key not at hand), the challenge expires and the SDK returns ACTION_CHALLENGE_EXPIRED. Your UI should offer a retry — client.action.sign() issues a fresh challenge each time.
Error codes
| Code | Cause | Recovery |
|---|---|---|
USER_CANCELLED | User dismissed the biometric prompt | Show a retry CTA |
ACTION_CHALLENGE_EXPIRED | Challenge TTL (5 min) exceeded | Call client.action.sign() again to issue a new challenge |
ACTION_ALREADY_CLAIMED | challenge_id already consumed — possible replay attack | Log the incident; do not re-execute the action |
ACTION_PAYLOAD_MISMATCH | Payload hash signed by device ≠ hash from backend | Check canonical JSON serialization on both sides |
NOT_SUPPORTED | Device/browser doesn’t support WebAuthn | Fall back to email OTP (client.otp) or block the action |
NETWORK_FAILURE | API unreachable | Retry with backoff; SDK retries automatically on transient errors |
Security guarantees
- Phishing-resistant: The WebAuthn assertion is bound to your origin (
rpId). A fake site cannot obtain a valid signature for yourrpId. - Anti-replay: Each
challenge_idis consumed atomically on first verification via RedisSET NX. A second verification of the same challenge always fails. - Payload integrity: The backend compares
payload_hashwith the challenge record. An attacker cannot substitute a different payload into an existing challenge. - Device-bound: The private key that signs the assertion never leaves the user’s device. Even if TryMellon’s API is compromised, historical signatures cannot be forged.
Backend integration notes
- Gate, then act: Verify the action signature before executing the operation. Never execute first.
- Store the
challenge_idin your audit log alongside the operation record for non-repudiation. - Do not reuse challenges: Issue a fresh challenge for each operation. A verified challenge cannot be re-verified.
- Idempotency: Treat
ACTION_ALREADY_CLAIMEDas a signal to check whether the underlying operation was already executed (e.g. from a client retry). Do not re-execute; return the existing result.