TryMellon
Navigation

Action Signing

Use passkeys to sign high-risk user actions — cryptographic proof of explicit intent with anti-replay guarantees.

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 caseWhy action signing fits
Wire transfers, crypto withdrawalsPrevents session hijacking from causing financial loss
Granting admin/owner permissionsNon-repudiable audit trail — user cannot claim they didn’t approve
Deleting an account or organizationExtra friction for irreversible operations
Changing MFA / recovery settingsPrevents an attacker with a valid session from locking out the user
High-value API key rotationTies 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 }
  1. The SDK calls POST /v1/actions/challenges with your payload hash.
  2. TryMellon returns a WebAuthn publicKeyCredentialRequestOptions challenge tied to that payload.
  3. The browser prompts the user (Face ID, Touch ID, Windows Hello, hardware key).
  4. The signed assertion is sent to POST /v1/actions/verify. TryMellon checks the signature, validates the payload hash, and marks the challenge as consumed.
  5. Each challenge is single-use — replaying the same challenge_id returns ACTION_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

CodeCauseRecovery
USER_CANCELLEDUser dismissed the biometric promptShow a retry CTA
ACTION_CHALLENGE_EXPIREDChallenge TTL (5 min) exceededCall client.action.sign() again to issue a new challenge
ACTION_ALREADY_CLAIMEDchallenge_id already consumed — possible replay attackLog the incident; do not re-execute the action
ACTION_PAYLOAD_MISMATCHPayload hash signed by device ≠ hash from backendCheck canonical JSON serialization on both sides
NOT_SUPPORTEDDevice/browser doesn’t support WebAuthnFall back to email OTP (client.otp) or block the action
NETWORK_FAILUREAPI unreachableRetry 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 your rpId.
  • Anti-replay: Each challenge_id is consumed atomically on first verification via Redis SET NX. A second verification of the same challenge always fails.
  • Payload integrity: The backend compares payload_hash with 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

  1. Gate, then act: Verify the action signature before executing the operation. Never execute first.
  2. Store the challenge_id in your audit log alongside the operation record for non-repudiation.
  3. Do not reuse challenges: Issue a fresh challenge for each operation. A verified challenge cannot be re-verified.
  4. Idempotency: Treat ACTION_ALREADY_CLAIMED as 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.