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 ────────────────────>│ persist { action_type, payload_hash, ttl }
│<── { challenge_id, webauthn_options, expires_at }
│
│ [browser WebAuthn prompt — user taps biometric]
│
│── POST /v1/actions/{challengeId}/verify ──────────>│ verify signature + consume challenge (atomic)
│<── { token, verified_at, action_type, credential_id }
- The SDK calls
POST /v1/actions/challengeswith{ action_type, payload_hash, rp_id, ttl_seconds? }.action_typefollows thenamespace:actionshape (e.g.approve:transfer). - TryMellon returns a WebAuthn
publicKeyCredentialRequestOptionschallenge tied to that payload, pluschallenge_idandexpires_at(default TTL 5 min, clamped to 60–900 s). - The browser prompts the user (Face ID, Touch ID, Windows Hello, hardware key).
- The signed assertion is sent to
POST /v1/actions/{challengeId}/verify— thechallengeIdtravels in the path, not the body. The backend re-verifies the storedpayload_hashagainst the challenge record and marks it consumed atomically. - Each challenge is single-use — replaying the same
challengeIdreturnsACTION_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/{challengeId}/verify before executing the action. Do not execute the action and then verify — the verification must gate the operation.
POST /v1/actions/{challengeId}/verify
Authorization: Bearer <session_token>
Content-Type: application/json
{
"authentication_response": { /* WebAuthn assertion — passed through from SDK result */ },
"rp_id": "your-app.com"
}
The
challengeIdreturned byPOST /v1/actions/challengestravels in the URL path, not in the body. Clients that POST to the legacy/v1/actions/verifywithout a path parameter will receive404 Not Found. Thepayload_hashis submitted when the challenge is issued and is verified server-side at consume time — clients do not re-send it on/verify.
Response — success:
{
"ok": true,
"data": {
"token": "act_...",
"verified_at": "2026-04-08T12:00:00Z",
"action_type": "approve:transfer",
"credential_id": "cred_..."
}
}
Forward the token to your backend as non-repudiable proof that the action was approved with the passkey bound to credential_id at verified_at.
Response — challenge already used:
{
"ok": false,
"error": { "code": "challenge_already_claimed", "message": "Challenge already verified" }
}
Wire vs SDK codes. Direct REST consumers see backend snake_case codes (
challenge_already_claimed,action_challenge_expired,action_payload_mismatch). The TryMellon SDK normalises these to SCREAMING_SNAKE_CASE (ACTION_ALREADY_CLAIMED,ACTION_CHALLENGE_EXPIRED,ACTION_PAYLOAD_MISMATCH) forresult.error.code. The Error codes table below uses the SDK codes — branch on those when consuming the SDK; branch on the wire codes when calling the API directly without the SDK.
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.