FaceSign
Use Cases

Step-Up from Anomaly Detection

Trigger FaceSign verification when your fraud system detects suspicious activity.

Your fraud detection platform already assigns risk scores to user actions. When that score crosses a threshold -- an unusual login location, a device change, or a spending spike -- you need to verify the user is who they claim to be without blocking legitimate activity. FaceSign provides a real-time step-up that integrates with any risk engine.


How it works

  1. Your fraud engine (Silverfort, Alloy, Sardine, or custom rules) evaluates a user action and returns a risk score.
  2. If the score exceeds your threshold, your backend creates a FaceSign session.
  3. You embed clientSecret.url as an iframe src inside your app — the step-up runs inline on your domain, keeping the user in your product. Redirecting the user to the hosted URL is the fallback when an iframe can't be used (e.g., strict parent-frame CSP, native mobile wrappers without a webview).
  4. FaceSign runs liveness detection and an AI conversation to confirm the user's intent.
  5. Your webhook handler receives the result and feeds it back to your risk engine for the final decision.

Integration architecture

User Action
    |
    v
+-------------------+     risk score     +------------------+
| Your Application  | -----------------> | Fraud Engine     |
+-------------------+                    | (Silverfort,     |
    |                                    |  Alloy, custom)  |
    |  score > threshold                 +------------------+
    |                                            ^
    v                                            |
+-------------------+     webhook        +-------+----------+
| FaceSign API      | -----------------> | Your Backend     |
| (step-up session) |                    | (decision logic) |
+-------------------+                    +------------------+

Build the integration

Define the trigger logic

Intercept user actions and check the risk score before allowing them to proceed. This pseudocode works with any risk engine that returns a numeric score.

middleware/risk-check.js
const RISK_THRESHOLD = 75 // Score 0-100; tune for your risk tolerance

async function checkRiskAndStepUp(userId, action, context) {
  // Query your fraud engine
  const riskScore = await fraudEngine.evaluate({
    userId,
    action, // e.g., 'change_email', 'add_payee', 'export_data'
    ip: context.ip,
    deviceFingerprint: context.deviceId,
    geoLocation: context.geo,
    timestamp: Date.now(),
  })

  if (riskScore < RISK_THRESHOLD) {
    // Low risk -- allow the action
    return { allowed: true }
  }

  // High risk -- require FaceSign step-up
  const verificationUrl = await createStepUpSession(
    userId, action, riskScore, context
  )

  return {
    allowed: false,
    requiresStepUp: true,
    verificationUrl,
  }
}
middleware/risk_check.py
import time

RISK_THRESHOLD = 75  # Score 0-100; tune for your risk tolerance

async def check_risk_and_step_up(user_id, action, context):
    # Query your fraud engine
    risk_score = await fraud_engine.evaluate(
        user_id=user_id,
        action=action,  # e.g., "change_email", "add_payee", "export_data"
        ip=context["ip"],
        device_fingerprint=context["device_id"],
        geo_location=context["geo"],
        timestamp=int(time.time() * 1000),
    )

    if risk_score < RISK_THRESHOLD:
        # Low risk -- allow the action
        return {"allowed": True}

    # High risk -- require FaceSign step-up
    verification_url = await create_step_up_session(
        user_id, action, risk_score, context
    )

    return {
        "allowed": False,
        "requires_step_up": True,
        "verification_url": verification_url,
    }

Create the FaceSign session

When the risk threshold is exceeded, create a verification session with liveness detection and a conversation node that describes the flagged action to the user.

services/step-up.js
async function createStepUpSession(userId, action, riskScore, context) {
  const user = await db.users.findById(userId)

  const res = await fetch('https://api.facesign.ai/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.FACESIGN_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      clientReferenceId: `stepup-${userId}-${Date.now()}`,
      metadata: {
        userId,
        action,
        riskScore,
        triggerIp: context.ip,
        triggerGeo: context.geo,
      },
      providedData: {
        email: user.email,
      },
      flow: [
        { id: 'start', type: 'start', outcome: 'greeting' },
        {
          id: 'greeting',
          type: 'conversation',
          prompt:
            "Say: Hi! We noticed something unusual on your account and want to make sure it's really you. This will only take a moment — I'll take a quick look, then ask you to confirm.",
          outcomes: [{ id: 'next', targetNodeId: 'liveness', condition: '' }],
        },
        {
          id: 'liveness',
          type: 'liveness_detection',
          outcomes: {
            livenessDetected: 'confirm_action',
            deepfakeDetected: 'closing',
            noFace: 'closing',
          },
        },
        {
          id: 'confirm_action',
          type: 'conversation',
          prompt: `Say: You're attempting to ${describeAction(action)} from ${context.geo || 'an unrecognized location'}. Can you confirm this is you and you intend to take this action?`,
          outcomes: [
            {
              id: 'confirmed',
              targetNodeId: 'email_otp',
              condition: 'The user explicitly confirmed the action is theirs',
            },
            {
              id: 'denied',
              targetNodeId: 'closing',
              condition: 'The user denied the action, disputed details, or expressed concern',
            },
            {
              id: 'stall',
              targetNodeId: 'closing',
              condition: 'Conversation reached 4 exchanges with no clear answer',
            },
          ],
        },
        {
          id: 'email_otp',
          type: 'two_factor_email',
          otpLength: 6,
          expirySeconds: 300,
          outcomes: {
            verified: 'closing',
            delivery_failed: 'closing',
            failed_unverified: 'closing',
            cancelled: 'closing',
          },
        },
        {
          id: 'closing',
          type: 'conversation',
          prompt:
            'Thank the user for completing the verification and tell them the session is now complete. Keep it to one sentence.',
          doesNotRequireReply: true,
          outcomes: [{ id: 'done', targetNodeId: 'end', condition: '' }],
        },
        { id: 'end', type: 'end' },
      ],
      videoAIAnalysisEnabled: true,
    }),
  })

  const { session, clientSecret } = await res.json()

  // Store the pending step-up
  await db.stepUpRequests.create({
    userId,
    action,
    riskScore,
    facesignSessionId: session.id,
    status: 'pending',
  })

  return clientSecret.url
}

function describeAction(action) {
  const descriptions = {
    change_email: 'change your email address',
    add_payee: 'add a new payment recipient',
    export_data: 'export your account data',
    change_password: 'change your password',
    large_transfer: 'make a large transfer',
  }
  return descriptions[action] || action
}
services/step_up.py
import os
import time
import requests

ACTION_DESCRIPTIONS = {
    "change_email": "change your email address",
    "add_payee": "add a new payment recipient",
    "export_data": "export your account data",
    "change_password": "change your password",
    "large_transfer": "make a large transfer",
}

async def create_step_up_session(user_id, action, risk_score, context):
    user = await db.users.find_by_id(user_id)
    action_desc = ACTION_DESCRIPTIONS.get(action, action)

    res = requests.post(
        "https://api.facesign.ai/sessions",
        headers={
            "Authorization": f"Bearer {os.environ['FACESIGN_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "clientReferenceId": f"stepup-{user_id}-{int(time.time() * 1000)}",
            "metadata": {
                "userId": user_id,
                "action": action,
                "riskScore": risk_score,
                "triggerIp": context["ip"],
                "triggerGeo": context.get("geo"),
            },
            "providedData": {
                "email": user["email"],
            },
            "flow": [
                {"id": "start", "type": "start", "outcome": "greeting"},
                {
                    "id": "greeting",
                    "type": "conversation",
                    "prompt": (
                        "Say: Hi! We noticed something unusual on your account "
                        "and want to make sure it's really you. This will only "
                        "take a moment — I'll take a quick look, then ask you "
                        "to confirm."
                    ),
                    "outcomes": [
                        {"id": "next", "targetNodeId": "liveness", "condition": ""}
                    ],
                },
                {
                    "id": "liveness",
                    "type": "liveness_detection",
                    "outcomes": {
                        "livenessDetected": "confirm_action",
                        "deepfakeDetected": "closing",
                        "noFace": "closing",
                    },
                },
                {
                    "id": "confirm_action",
                    "type": "conversation",
                    "prompt": (
                        f"Say: You're attempting to {action_desc} from "
                        f"{context.get('geo', 'an unrecognized location')}. "
                        f"Can you confirm this is you and you intend to take this action?"
                    ),
                    "outcomes": [
                        {
                            "id": "confirmed",
                            "targetNodeId": "email_otp",
                            "condition": "The user explicitly confirmed the action is theirs",
                        },
                        {
                            "id": "denied",
                            "targetNodeId": "closing",
                            "condition": "The user denied the action, disputed details, or expressed concern",
                        },
                        {
                            "id": "stall",
                            "targetNodeId": "closing",
                            "condition": "Conversation reached 4 exchanges with no clear answer",
                        },
                    ],
                },
                {
                    "id": "email_otp",
                    "type": "two_factor_email",
                    "otpLength": 6,
                    "expirySeconds": 300,
                    "outcomes": {
                        "verified": "closing",
                        "delivery_failed": "closing",
                        "failed_unverified": "closing",
                        "cancelled": "closing",
                    },
                },
                {
                    "id": "closing",
                    "type": "conversation",
                    "prompt": (
                        "Thank the user for completing the verification and "
                        "tell them the session is now complete. Keep it to one sentence."
                    ),
                    "doesNotRequireReply": True,
                    "outcomes": [
                        {"id": "done", "targetNodeId": "end", "condition": ""}
                    ],
                },
                {"id": "end", "type": "end"},
            ],
            "videoAIAnalysisEnabled": True,
        },
    )

    data = res.json()
    session = data["session"]
    client_secret = data["clientSecret"]

    # Store the pending step-up
    await db.step_up_requests.create(
        user_id=user_id,
        action=action,
        risk_score=risk_score,
        facesign_session_id=session["id"],
        status="pending",
    )

    return client_secret["url"]

Handle the webhook and update your risk engine

When the session completes, feed the FaceSign result back to your fraud engine so it can update its risk model.

api/webhooks/facesign.js
import crypto from 'node:crypto'

// Verify an HMAC-SHA256 signature of the form `t=<timestamp>,v1=<hex>`
// over `${timestamp}.${rawBody}`, using the webhook secret from the
// FaceSign dashboard. Reject any request whose signature doesn't match.
function verifyFacesignSignature(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('='))
  )
  if (!parts.t || !parts.v1) return false
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`, 'utf8')
    .digest('hex')
  try {
    return crypto.timingSafeEqual(
      Buffer.from(parts.v1, 'hex'),
      Buffer.from(expected, 'hex')
    )
  } catch {
    return false
  }
}

export async function POST(req) {
  // Read the raw body before parsing — signature is computed over the
  // exact bytes we received.
  const rawBody = await req.text()
  const signature = req.headers.get('x-facesign-signature')

  if (!verifyFacesignSignature(rawBody, signature, process.env.FACESIGN_WEBHOOK_SECRET)) {
    return new Response('Invalid signature', { status: 401 })
  }

  const event = JSON.parse(rawBody)

  if (event.type !== 'session.status') {
    return Response.json({ received: true })
  }

  // Fetch the full session. GET /sessions/:id returns
  // `{ session, clientSecret }` — unpack the inner session.
  const sessionRes = await fetch(
    `https://api.facesign.ai/sessions/${event.sessionId}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.FACESIGN_API_KEY}`,
      },
    }
  )
  const { session } = await sessionRes.json()

  const stepUp = await db.stepUpRequests.findBy({
    facesignSessionId: event.sessionId,
  })

  if (!stepUp) {
    return Response.json({ received: true })
  }

  // Evaluate results
  const livenessReport = session.report?.nodeReports
    ?.find((r) => r.nodeId === 'liveness')
  const otpReport = session.report?.nodeReports
    ?.find((r) => r.nodeId === 'email_otp')

  const livenessPass = livenessReport?.outcome === 'livenessDetected'
  const otpPass = otpReport?.outcome === 'verified'
  const verified = session.status === 'complete' && livenessPass && otpPass

  // Feed result back to your fraud engine
  await fraudEngine.reportStepUpResult({
    userId: stepUp.userId,
    action: stepUp.action,
    originalRiskScore: stepUp.riskScore,
    facesignVerified: verified,
    facesignSessionId: session.id,
    livenessOutcome: livenessReport?.outcome,
  })

  if (verified) {
    // Allow the original action to proceed
    await db.stepUpRequests.update(stepUp.id, { status: 'verified' })
    await actionQueue.release(stepUp.userId, stepUp.action)
  } else {
    // Block the action and flag the account
    await db.stepUpRequests.update(stepUp.id, { status: 'failed' })
    await fraudEngine.escalate({
      userId: stepUp.userId,
      reason: 'step_up_verification_failed',
      livenessOutcome: livenessReport?.outcome,
    })
  }

  return Response.json({ received: true })
}
api/webhooks/facesign.py
import hmac, hashlib, json, os
from flask import Flask, request, jsonify, abort
import requests

app = Flask(__name__)


def verify_facesign_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
    """HMAC-SHA256 of ``{timestamp}.{raw_body}`` must match ``v1`` in the header."""
    if not signature_header:
        return False
    try:
        parts = dict(p.split("=", 1) for p in signature_header.split(","))
    except ValueError:
        return False
    if "t" not in parts or "v1" not in parts:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{parts['t']}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)


@app.route("/webhooks/facesign", methods=["POST"])
def handle_webhook():
    # Read the raw body before parsing — signature is computed over the
    # exact bytes we received.
    raw_body = request.get_data()
    signature = request.headers.get("X-FaceSign-Signature")

    if not verify_facesign_signature(
        raw_body, signature, os.environ["FACESIGN_WEBHOOK_SECRET"]
    ):
        abort(401, description="Invalid signature")

    event = json.loads(raw_body)

    if event["type"] != "session.status":
        return jsonify({"received": True}), 200

    # Fetch the full session. GET /sessions/:id returns
    # {"session": ..., "clientSecret": ...} — unpack the inner session.
    session_res = requests.get(
        f"https://api.facesign.ai/sessions/{event['sessionId']}",
        headers={
            "Authorization": f"Bearer {os.environ['FACESIGN_API_KEY']}",
        },
    )
    session = session_res.json()["session"]

    step_up = db.step_up_requests.find_by(
        facesign_session_id=event["sessionId"]
    )

    if not step_up:
        return jsonify({"received": True}), 200

    # Evaluate results
    node_reports = (session.get("report") or {}).get("nodeReports") or []
    liveness_report = next(
        (r for r in node_reports if r["nodeId"] == "liveness"), None
    )
    otp_report = next(
        (r for r in node_reports if r["nodeId"] == "email_otp"), None
    )

    liveness_pass = (
        liveness_report
        and liveness_report.get("outcome") == "livenessDetected"
    )
    otp_pass = otp_report and otp_report.get("outcome") == "verified"
    verified = session["status"] == "complete" and liveness_pass and otp_pass

    # Feed result back to your fraud engine
    fraud_engine.report_step_up_result(
        user_id=step_up["user_id"],
        action=step_up["action"],
        original_risk_score=step_up["risk_score"],
        facesign_verified=verified,
        facesign_session_id=session["id"],
        liveness_outcome=liveness_report.get("outcome") if liveness_report else None,
    )

    if verified:
        # Allow the original action to proceed
        db.step_up_requests.update(step_up["id"], status="verified")
        action_queue.release(step_up["user_id"], step_up["action"])
    else:
        # Block the action and flag the account
        db.step_up_requests.update(step_up["id"], status="failed")
        fraud_engine.escalate(
            user_id=step_up["user_id"],
            reason="step_up_verification_failed",
            liveness_outcome=liveness_report.get("outcome") if liveness_report else None,
        )

    return jsonify({"received": True}), 200

Risk engine integration patterns

How you integrate depends on your fraud detection platform:

PlatformIntegration pointPattern
SilverfortPolicy engine webhookSilverfort flags the action, your middleware intercepts and creates a FaceSign session, then reports the result back via Silverfort's API
AlloyJourney orchestrationUse Alloy's webhook step to trigger FaceSign session creation; report the outcome as a journey event
SardineRisk scoring APICheck Sardine's risk score in your middleware; create FaceSign session if score exceeds threshold
Custom rules engineYour own middlewareEvaluate rules, trigger FaceSign, feed result back to your rules database

Choosing the right flow nodes

Every step-up flow starts with a short greeting conversation (so the camera has enough video for liveness) and ends with a closing conversation — these are required regardless of tier. What varies between tiers is what sits between them:

Risk levelActionsVerification nodes between greeting and closing
Medium (score 60-75)Password change, email changeLiveness + Email OTP
High (score 75-90)Add payee, data exportLiveness + confirmation conversation + Email OTP
Critical (score 90+)Large transfer, account closureLiveness + confirmation conversation + Email OTP, with videoAIAnalysisEnabled: true to pick up coercion signals in post-session analysis

videoAIAnalysisEnabled can be left on for all tiers — the example flow sets it to true — but it's most valuable at the critical tier where the extra analysis cost is easily justified.


Troubleshooting

SymptomCauseFix
Step-up triggered on every actionRisk threshold is too lowIncrease RISK_THRESHOLD; review your fraud engine's scoring calibration
Step-up never triggersRisk threshold is too high or fraud engine is not evaluating the actionLower the threshold; verify the action type is included in your risk evaluation
User completes verification but action stays blockedWebhook handler does not release the queued actionVerify actionQueue.release() is called on successful verification
Fraud engine does not learn from FaceSign resultsResult feedback is not implementedCall your fraud engine's feedback API with the FaceSign outcome after each session
Deepfake detected but no alert firesMissing escalation logic for deepfakeDetectedAdd an explicit check for deepfakeDetected in your webhook handler and trigger an alert

Next Steps

On this page