faceping.aidocs

Signed check results

Prove on your server that a check-in device really checked someone. Every result is signed by the device, and FacePing or your own code checks the signature.

A check happens on the device, offline, and the result goes to your app. When your server needs to rely on that result (a staff or member check-in, a door that opens, a reward that's paid out), it needs proof that the device really returned it, and that nobody edited the app or replayed an old result. Every check-in device signs every result it returns, and your server checks the signature.

This works for check-in devices (an app started with a device token, fpd_), including devices on your practice list with a sandbox key. A sandbox app that enrols faces on the phone has no device identity, so its results aren't signed (signedResult is null).

How it works#

  1. A key for each device. The first time an app runs as a check-in device, FacePing creates a signing key (P-256) on it. The private key stays on the device, encrypted in FacePing's store and protected by the platform's key store (Android Keystore, iOS Keychain). It is kept when you move the device to another list or unpair it, and goes only when the app's data is cleared or the app is reinstalled.
  2. Registered at the first sync. The device sends its public key with its first sync, and FacePing keeps it with that device. The first key is the one that counts: if the device later sends a different one, checks keep working but its new results won't verify (the sync tells the app, and the dashboard shows Key mismatch). Add the device again, with a new token, to register the new key.
  3. Every outcome is signed. Each check on the device returns signedResult, a compact JWS (ES256), for a match and for every other outcome, so your server sees refusals too. It holds no face data, photos or scores.
  4. Your server verifies it. Send it to POST /v1/check-results/verify, which checks everything and tells you what the device found. Or verify it yourself with the device's public key.

What a signed result says#

The header is {"alg":"ES256","typ":"faceping-check+jwt","kid":"…"}, where kid names the device's key. The claims:

Claim Meaning
v 1
iss faceping-device
dev The device's id (the id from adding the device)
lst The check-in list the device is on
pid The personId that was checked
out The outcome: match, noMatch, notEnrolled, noFace, livenessFailed, challengeFailed, expired or leaseExpired
live true for a live check (with liveness), false for a single photo
iat When the device checked, by its clock (Unix seconds)
seq A counter on the device that goes up with every signed result
jti A random id, unique to this result
nonce Your nonce, when the app passed one
sdk The SDK and version, for example flutter/1.0.0-preview.6

1. Give the app a nonce for each check#

When the device can reach your server as the check starts, issue a nonce: a random, single-use string (up to 128 characters) that your server remembers for a few minutes. The device signs it into the result, so a result can only answer that one request: an old result, or one copied from another device, won't carry it. The nonce is optional; without a connection, see Offline and queued results.

Node.js (your server)
import { randomBytes } from 'node:crypto';

app.post('/checks/nonce', requireStaffDevice, async (req, res) => {
  const nonce = randomBytes(16).toString('base64url');
  await nonces.save(nonce, { device: req.device.id, expiresAt: Date.now() + 5 * 60_000 });
  res.json({ nonce });
});

2. Pass it to the check and send the result#

Pass the nonce to the live check (or to a single-photo verify), then send signedResult to your server. The app can show the outcome straight away; your server decides what counts.

var nonce = await backend.NewCheckNonceAsync();                       // your server, step 1
var result = await faceping.VerifyLiveAsync(scannedId, Camera, nonce);
if (result.SignedResult is { } signedResult)
    await backend.RecordCheckAsync(signedResult);                     // your server, step 3
var nonce = await backend.NewCheckNonceAsync();
var result = await IFacePing.Current.VerifyLiveAsync(scannedId, Camera, nonce);
if (result.SignedResult is { } signedResult)
    await backend.RecordCheckAsync(signedResult);
val nonce = backend.newCheckNonce()
val result = FacePing.current.verifyLive(scannedId, camera, nonce = nonce)
result.signedResult?.let { backend.recordCheck(it) }
let nonce = try await backend.newCheckNonce()
let result = try await faceping.verifyLive(id: scannedId, camera: camera, nonce: nonce)
if let signedResult = result.signedResult {
    try await backend.recordCheck(signedResult)
}
final nonce = await backend.newCheckNonce();
final result = await FacePing.current.verifyLive(scannedId, camera, nonce: nonce);
final signedResult = result.signedResult;
if (signedResult != null) await backend.recordCheck(signedResult);
const nonce = await backend.newCheckNonce();
const result = await FacePing.verifyLive(scannedId, camera, { nonce });
if (result.signedResult) await backend.recordCheck(result.signedResult);
const nonce = await backend.newCheckNonce();
const result = await FacePing.verifyLive(scannedId, undefined, { nonce });   // camera screen options, then { nonce }
if (result.signedResult) await backend.recordCheck(result.signedResult);

verify (a single photo) takes a nonce the same way. A nonce is 1 to 128 characters with no control characters; anything else is refused before the check starts.

Watch the signing state#

Each sync says whether FacePing holds this device's key: signing is registered, mismatch, or unknown (before the server has said, and in sandbox-key apps). On a mismatch, show staff a warning: checks still work, but the server won't accept the device's results until it's added again.

faceping.Synced += (_, sync) =>
{
    if (sync.Signing == Signing.Mismatch)
        MainThread.BeginInvokeOnMainThread(() => Warning.Text = "Add this device again: its signed results won't verify.");
};
IFacePing.Current.Synced += (_, sync) =>
{
    if (sync.Signing == Signing.Mismatch)
        Dispatcher.UIThread.Post(() => Warning.Text = "Add this device again: its signed results won't verify.");
};
lifecycleScope.launch {
    FacePing.current.synced.collect { sync ->
        if (sync.signing == SigningState.MISMATCH) warning.text = "Add this device again: its signed results won't verify."
    }
}
for await sync in faceping.synced where sync.signing == .mismatch {
    warning = "Add this device again: its signed results won't verify."
}
FacePing.current.synced.listen((sync) {
  if (sync.signing == SigningState.mismatch) {
    setState(() => warning = 'Add this device again: its signed results won\'t verify.');
  }
});
const sub = FacePing.addListener('synced', (sync) => {
  if (sync.signing === 'mismatch') setWarning("Add this device again: its signed results won't verify.");
});
await FacePing.addListener('synced', (sync) => {
  if (sync.signing === 'mismatch') warning.textContent = "Add this device again: its signed results won't verify.";
});

3. Verify it on your server#

Your server sends the signed result, and the nonce it issued, to FacePing with your API key (a sandbox key verifies results from your practice list):

POST /v1/check-results/verify →

curl -X POST https://api-eu.faceping.ai/v1/check-results/verify \
  -H "X-Api-Key: $FACEPING_API_KEY" -H "Content-Type: application/json" \
  -d '{"signedResult":"eyJhbGciOiJFUzI1NiIs…","nonce":"q3Jb9tZ1VfS0a8d2Lr7xNw"}'

FacePing checks the signature against the key the device registered, that the device is on one of your lists, that the nonce matches (when you send one), that the result was signed less than 30 days ago and not in the future, and that it hasn't been verified before. Then it answers with what the device found:

{
  "match": true,
  "outcome": "match",
  "personId": "STAFF-0042",
  "listId": "0c8e4d2a-1b3f-4e6a-8d9c-7f5e2a1b3c4d",
  "deviceId": "6f1c2b0e-8d4f-4a57-9c3e-2b7d1a0e5f42",
  "deviceLabel": "Staff entrance",
  "live": true,
  "checkedAt": "2026-11-12T08:59:41+00:00",
  "seq": 214,
  "seqGap": false,
  "jti": "Jkc11zB_nkx8NJBzKPSqvQ",
  "nonce": "q3Jb9tZ1VfS0a8d2Lr7xNw",
  "sdk": "flutter/1.0.0-preview.6",
  "keyId": "_WrIS4adv2RkkXPLm7zYYA",
  "deviceRevoked": false,
  "revokedAt": null,
  "verifiedAt": "2026-11-12T08:59:42+00:00"
}

match is true only for a match; outcome has the detail. Check personId is the person you expected, and decide what a result from a since-revoked device means for you (deviceRevoked: results it signed before still verify).

Each result verifies once. A second attempt gets 409 with reason: "replayed". When a result doesn't verify, the answer is 422 with a reason and a message:

reason Meaning
malformed Not a FacePing signed check result. Send signedResult unchanged.
badSignature The result was altered, or wasn't signed by this device.
unknownKey Signed by a key FacePing doesn't hold for the device, usually after the app was reinstalled or its data cleared. Add the device again.
unknownDevice No device with that id on that list (it may have gone with its list).
wrongAccount The device is on another account (or, with a sandbox key, not on your practice list).
nonceMismatch The result's nonce isn't the one you sent, or it has none.
tooOld Signed more than 30 days ago.
future Signed more than 5 minutes in the future: check the device's clock.
replayed (409) Already verified once.

Verify it yourself#

If your server would rather not call FacePing for each check, verify the signature with the device's public key. GET /v1/lists/{listId}/devices gives each device's keyId, publicKey (a JSON Web Key, P-256) and keyRegisteredAt (null until its first sync), plus keyMismatchAt if it has sent a different key since. Cache the keys and look one up by the result's dev (or kid).

using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

// jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or throws.
static JsonElement VerifyCheckResult(string signedResult, JsonElement jwk, string? nonce = null)
{
    var parts = signedResult.Split('.');
    if (parts.Length != 3) throw new InvalidDataException("Not a signed check result.");
    var header = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[0])).RootElement;
    if (header.GetProperty("alg").GetString() != "ES256" || header.GetProperty("typ").GetString() != "faceping-check+jwt"
        || header.GetProperty("kid").GetString() != jwk.GetProperty("kid").GetString())
        throw new InvalidDataException("Not signed by this device's key.");

    using var key = ECDsa.Create(new ECParameters
    {
        Curve = ECCurve.NamedCurves.nistP256,
        Q = new ECPoint
        {
            X = Base64Url.DecodeFromChars(jwk.GetProperty("x").GetString()),
            Y = Base64Url.DecodeFromChars(jwk.GetProperty("y").GetString())
        }
    });
    var signed = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
    if (!key.VerifyData(signed, Base64Url.DecodeFromChars(parts[2]), HashAlgorithmName.SHA256))   // r || s, as JWS uses
        throw new InvalidDataException("Bad signature.");

    var claims = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1])).RootElement;
    if (claims.GetProperty("iss").GetString() != "faceping-device" || claims.GetProperty("v").GetInt32() != 1)
        throw new InvalidDataException("Not a FacePing check result.");
    if (nonce != null && (!claims.TryGetProperty("nonce", out var n) || n.GetString() != nonce))
        throw new InvalidDataException("Nonce mismatch.");
    return claims;
}
import { createPublicKey, verify } from 'node:crypto';

// jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or throws.
export function verifyCheckResult(signedResult, jwk, nonce) {
  const [h, p, s] = signedResult.split('.');
  if (!s) throw new Error('Not a signed check result.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg !== 'ES256' || header.typ !== 'faceping-check+jwt' || header.kid !== jwk.kid) {
    throw new Error("Not signed by this device's key.");
  }
  const key = createPublicKey({ key: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }, format: 'jwk' });
  const ok = verify('sha256', Buffer.from(`${h}.${p}`), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(s, 'base64url'));
  if (!ok) throw new Error('Bad signature.');
  const claims = JSON.parse(Buffer.from(p, 'base64url'));
  if (claims.iss !== 'faceping-device' || claims.v !== 1) throw new Error('Not a FacePing check result.');
  if (nonce !== undefined && claims.nonce !== nonce) throw new Error('Nonce mismatch.');
  return claims;
}
# pip install "pyjwt[crypto]"
import json
import jwt
from jwt.algorithms import ECAlgorithm

def verify_check_result(signed_result: str, jwk: dict, nonce: str | None = None) -> dict:
    """jwk: the device's publicKey from GET /v1/lists/{listId}/devices. Returns the claims, or raises."""
    header = jwt.get_unverified_header(signed_result)
    if header.get("typ") != "faceping-check+jwt" or header.get("kid") != jwk["kid"]:
        raise ValueError("Not signed by this device's key.")
    key = ECAlgorithm.from_jwk(json.dumps(jwk))
    claims = jwt.decode(signed_result, key, algorithms=["ES256"], issuer="faceping-device",
                        options={"require": ["iat", "jti", "dev", "lst", "pid", "out"]})
    if claims.get("v") != 1:
        raise ValueError("Not a FacePing check result.")
    if nonce is not None and claims.get("nonce") != nonce:
        raise ValueError("Nonce mismatch.")
    return claims

The signature proves the device signed the result. The rest is then up to your server, as FacePing's endpoint does it:

  • Replays: store each jti you accept (for at least 30 days) and refuse one you've seen.
  • Whose device: dev and lst must be a device and a list on your account, and the key must be the one you cached for that device.
  • Time: refuse an iat too far in the past or ahead of your clock.

Offline and queued results#

A signed result is self-contained, so a device without a connection can queue it and send it when it's back online. Without a nonce, rely on:

  • jti: each result verifies once, so a queued result can't be counted twice.
  • iat: when the device checked. FacePing accepts results up to 30 days old.
  • The lease: a device stops checking people 24 hours after its last sync, so a signed result never comes from a device that's been out of touch for longer than that.
  • seq counts up on each device. Queued results can arrive out of order, so FacePing reports seqGap: true (for information) when a result's seq isn't above every one it has verified for that device. A run of gaps from one device is worth a look.

Nonces#

Issue a fresh nonce for each check, keep it for a few minutes, and accept it once. Send it to FacePing with the result (or compare it yourself): a result that doesn't carry it is nonceMismatch. Don't reuse nonces across devices or checks, and don't put personal data in them; any random string up to 128 characters works.

Limits#

  • The key isn't held in secure hardware. It is encrypted on the device and its encryption key is protected by the platform's key store, but the signing itself runs in FacePing's code inside your app. On a phone that's been rooted or jailbroken, a determined attacker who can run code inside your app could sign results with it. To make that much harder, check the app itself: use Play Integrity on Android and App Attest on iOS, with your server's nonce, and only accept results from devices that pass.
  • It proves the device's answer, not who stood in front of it. Liveness and the head-turn check are what make that hard; see Anti-spoofing. Keep staff in view of unattended devices.
  • Reinstalling the app makes a new key. Add the device again (a new token) after a reinstall or after its data is cleared.

Example: staff or member check-in, or a door#

  1. Each person's personId is your staff or member number, enrolled on an ongoing list. Not everyone has to use their face: offer a non-face way in (a badge, a PIN, a member of staff), as FacePing requires for every list.
  2. A device at the entrance is paired with the list's device token.
  3. Someone scans their badge. The app asks your server for a nonce, runs the live check against the badge's personId with that nonce, and sends signedResult to your server.
  4. Your server verifies it (FacePing's endpoint, or your own code), checks personId is the badge holder, and records the check-in or opens the door. Anything other than a match goes to your non-face route.

Staff and member uses of face verification carry their own legal duties (for example, a data protection impact assessment and a clear alternative). See Privacy and compliance.