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#
- 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.
- 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.
- 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. - 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.
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 3var 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 claimsThe signature proves the device signed the result. The rest is then up to your server, as FacePing's endpoint does it:
- Replays: store each
jtiyou accept (for at least 30 days) and refuse one you've seen. - Whose device:
devandlstmust be a device and a list on your account, and the key must be the one you cached for that device. - Time: refuse an
iattoo 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.
seqcounts up on each device. Queued results can arrive out of order, so FacePing reportsseqGap: true(for information) when a result'sseqisn'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#
- Each person's
personIdis 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. - A device at the entrance is paired with the list's device token.
- Someone scans their badge. The app asks your server for a nonce, runs the live check against the badge's
personIdwith that nonce, and sendssignedResultto your server. - Your server verifies it (FacePing's endpoint, or your own code), checks
personIdis 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.