Two files. One call each side.
The browser SDK opens the camera on our origin, runs the liveness ceremony
and hands your page a signed token. The server SDK turns that token into human: true
and a subject that is the same person forever. Nothing else to install.
<script src="https://faceguid.com/sdk/faceguid.js"></script>
<script>
const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
document.querySelector('#verify').addEventListener('click', async () => {
// A nonce from your own server ties this proof to this one attempt.
const { nonce } = await (await fetch('/api/nonce')).json();
const proof = await fg.proveHuman({ nonce });
// The token means nothing until your backend verifies it.
const res = await fetch('/api/human', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: proof.token, nonce }),
});
if ((await res.json()).ok) location.reload();
});
</script>
import express from 'express';
import { FaceGUIDServer } from 'https://faceguid.com/sdk/faceguid-server.js';
const fg = new FaceGUIDServer({
clientId: process.env.FACEGUID_CLIENT_ID,
clientSecret: process.env.FACEGUID_CLIENT_SECRET, // server-side only, always
});
const app = express();
app.use(express.json());
// Hand out a nonce so a proof cannot be replayed from another session.
const nonces = new Map();
app.get('/api/nonce', (req, res) => {
const nonce = crypto.randomUUID();
nonces.set(nonce, Date.now());
res.json({ nonce });
});
app.post('/api/human', async (req, res) => {
const { token, nonce } = req.body;
if (!nonces.delete(nonce)) return res.status(400).json({ error: 'unknown_nonce' });
try {
const proof = await fg.verifyProof(token, { nonce });
// proof.human -> true
// proof.sub -> the same value for this person, in your app, forever
// proof.verifications -> how many times this face has ever verified
// proof.enrolled_at -> unix seconds; a minutes-old identity is a weak signal
req.session.human = proof.sub;
res.json({ ok: true, sub: proof.sub });
} catch (err) {
res.status(403).json({ error: err.code }); // token_already_used, expired, bad_signature...
}
});
This page loads the same SDK file you would. The proof it returns is real — and useless to anyone without the matching client secret.
Installing
Script tag
The classic build defines window.FaceGUID. Works in anything, including
a CMS template you cannot rebuild.
<script src="https://faceguid.com/sdk/faceguid.js"></script>
ES module
Same implementation, module wrapper. Import it straight from our origin or vendor it.
import FaceGUID from 'https://faceguid.com/sdk/faceguid.esm.js';
const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
const proof = await fg.proveHuman({ nonce });
Vendored
Both files are dependency-free and versioned. Copy them into your repo if you would rather not fetch anything at runtime.
curl -O https://faceguid.com/sdk/faceguid.js curl -O https://faceguid.com/sdk/faceguid-server.js
Verifying, in your stack
The server side is one authenticated POST. Use the SDK if it helps; the raw call is small enough that plenty of teams never bother.
import express from 'express';
import { FaceGUIDServer } from 'https://faceguid.com/sdk/faceguid-server.js';
const fg = new FaceGUIDServer({
clientId: process.env.FACEGUID_CLIENT_ID,
clientSecret: process.env.FACEGUID_CLIENT_SECRET, // server-side only, always
});
const app = express();
app.use(express.json());
// Hand out a nonce so a proof cannot be replayed from another session.
const nonces = new Map();
app.get('/api/nonce', (req, res) => {
const nonce = crypto.randomUUID();
nonces.set(nonce, Date.now());
res.json({ nonce });
});
app.post('/api/human', async (req, res) => {
const { token, nonce } = req.body;
if (!nonces.delete(nonce)) return res.status(400).json({ error: 'unknown_nonce' });
try {
const proof = await fg.verifyProof(token, { nonce });
// proof.human -> true
// proof.sub -> the same value for this person, in your app, forever
// proof.verifications -> how many times this face has ever verified
// proof.enrolled_at -> unix seconds; a minutes-old identity is a weak signal
req.session.human = proof.sub;
res.json({ ok: true, sub: proof.sub });
} catch (err) {
res.status(403).json({ error: err.code }); // token_already_used, expired, bad_signature...
}
});
// Or let the middleware do it: reads x-faceguid-proof, sets req.human.
app.post('/comment', fg.middleware(), (req, res) => {
saveComment({ author: req.human.sub, body: req.body.text });
res.json({ ok: true });
});
import { FaceGUIDServer } from 'https://faceguid.com/sdk/faceguid-server.js';
export default {
async fetch(request, env) {
const fg = new FaceGUIDServer({
clientId: env.FACEGUID_CLIENT_ID,
clientSecret: env.FACEGUID_CLIENT_SECRET,
});
const { token } = await request.json();
try {
const proof = await fg.verifyProof(token);
return Response.json({ ok: true, sub: proof.sub });
} catch (err) {
return Response.json({ error: err.code }, { status: 403 });
}
},
};// app/api/human/route.js — Next.js route handler
import { FaceGUIDServer } from '@/lib/faceguid-server'; // vendored copy of the SDK
const fg = new FaceGUIDServer({
clientId: process.env.FACEGUID_CLIENT_ID,
clientSecret: process.env.FACEGUID_CLIENT_SECRET,
});
export async function POST(request) {
const { token, nonce } = await request.json();
try {
const proof = await fg.verifyProof(token, { nonce });
return Response.json({ ok: true, sub: proof.sub });
} catch (err) {
return Response.json({ error: err.code }, { status: 403 });
}
}# No SDK needed: it is one authenticated POST.
import os, requests
def verify_proof(token: str, nonce: str | None = None) -> dict:
res = requests.post(
"https://faceguid.com/api/proof/verify",
auth=(os.environ["FACEGUID_CLIENT_ID"], os.environ["FACEGUID_CLIENT_SECRET"]),
json={"token": token, "nonce": nonce},
timeout=5,
)
body = res.json()
if res.status_code != 200 or not body.get("human"):
raise ValueError(body.get("error", "proof_rejected"))
return body # sub, verifications, enrolled_at, acr, amr, jti
# Django / Flask view
def human_required(view):
def wrapper(request, *a, **kw):
try:
request.human = verify_proof(request.headers["X-FaceGUID-Proof"])
except (KeyError, ValueError):
return HttpResponseForbidden()
return view(request, *a, **kw)
return wrapper<?php
// Same call, no dependencies.
function faceguid_verify(string $token, ?string $nonce = null): array {
$ch = curl_init('https://faceguid.com/api/proof/verify');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERPWD => getenv('FACEGUID_CLIENT_ID') . ':' . getenv('FACEGUID_CLIENT_SECRET'),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['token' => $token, 'nonce' => $nonce]),
]);
$body = json_decode(curl_exec($ch), true);
if (empty($body['human'])) {
throw new RuntimeException($body['error'] ?? 'proof_rejected');
}
return $body;
}# Verify a proof. This is the whole server-side integration.
curl -X POST https://faceguid.com/api/proof/verify \
-u "$FACEGUID_CLIENT_ID:$FACEGUID_CLIENT_SECRET" \
-H "content-type: application/json" \
-d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImZhY2VndWlkLXByb29mK2p3dCJ9..."}'
# {
# "human": true,
# "sub": "6f1c2d0e-9a3b-4c7d-8e21-5b0a9f3c1d84",
# "nonce": "b7f2…",
# "amr": ["face", "liveness"],
# "acr": "faceguid:liveness:speak-digits",
# "auth_time": 1766745600,
# "enrolled_at": 1740873600,
# "verifications": 37,
# "second_factor": false,
# "new_identity": false,
# "expires_at": 1766745900,
# "jti": "0c2f…"
# }
# Spending it a second time is refused — that is the replay guard.
# HTTP 409 {"human":false,"error":"token_already_used"}Browser SDK reference
| Call | What it does |
|---|---|
new FaceGUID({ clientId, origin?, timeout?, styled? }) |
Creates a client. origin only changes for a self-hosted deployment. |
await fg.proveHuman({ nonce?, ttl?, mode? }) |
Opens the verification window and resolves with { token, sub, expiresAt }.
Call it from a click, or the browser blocks the popup. |
fg.mount(target, options) |
Renders a styled button that runs the whole flow. Pass styled: false on the
constructor to get an unstyled .fg-button you can theme. |
FaceGUID.readProofFromUrl() |
Redirect mode: pulls the proof out of the fragment, checks the state, cleans the URL. |
await fg.signIn({ scope, redirectUri }) |
Full OpenID Connect sign-in with PKCE. Navigates away. |
await fg.completeSignIn() |
Exchanges the returned code for tokens. null on a normal page load. |
const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
fg.mount('#verify-here', {
label: 'Verify I am human',
getNonce: async () => (await (await fetch('/api/nonce')).json()).nonce,
onProof: async (proof) => {
await fetch('/api/human', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: proof.token }),
});
},
onError: (err) => console.warn(err.code, err.message),
});
// Popups are awkward inside some in-app browsers. Redirect instead:
await fg.proveHuman({ mode: 'redirect', redirectUri: 'https://example.com/verified' });
// ...and on the way back in, on that page:
const proof = FaceGUID.readProofFromUrl(); // null on a normal page load
if (proof) await fetch('/api/human', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token: proof.token }),
});
Server SDK reference
| Call | What it does |
|---|---|
await fg.verifyProof(token, { nonce? }) |
The authoritative check. Verifies the signature, the audience, the expiry and the nonce, then spends the token. A second call is a 409. |
await fg.verifyProofOffline(token, { redeem? }) |
Signature-only verification against the cached JWKS. Fast; pass redeem: true
to keep the replay guard. |
await fg.redeem(jti) |
Marks a token spent after you verified it yourself. |
fg.middleware({ required }) |
Express/Connect middleware. Reads x-faceguid-proof, sets req.human. |
await fg.verifyIdToken(idToken) |
Verifies an OpenID Connect id_token from the sign-in flow. |
fg.authorizeUrl(…), fg.exchangeCode(…), fg.userinfo(…) |
The server-side half of the sign-in flow, for stacks without an OIDC library. |
// Verify the signature locally — no round trip — then spend the jti.
const proof = await fg.verifyProofOffline(token, { nonce, redeem: true });
// Or entirely by hand, against the published JWKS:
const { keys } = await (await fetch('https://faceguid.com/.well-known/jwks.json')).json();
const [h, p, s] = token.split('.');
const header = JSON.parse(atob(h.replace(/-/g, '+').replace(/_/g, '/')));
const jwk = keys.find(k => k.kid === header.kid);
const key = await crypto.subtle.importKey('jwk', jwk,
{ name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key,
Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
new TextEncoder().encode(`${h}.${p}`));
// Offline verification proves the token is genuine and unexpired.
// It does NOT prove it is unspent — that is what /api/proof/redeem is for.
What is in a proof
| Claim | Meaning |
|---|---|
human | Always true in a proof. A token without it is not one. |
sub | The person, as your client sees them. Stable forever; different at every other client. |
faceguid | The real global GUID. Only present when your client has pairwise subjects turned off. |
nonce | Whatever you passed in. Check it, and a proof cannot be lifted from another session. |
acr / amr | The ceremony that was passed: faceguid:liveness:speak-digits, ["face","liveness"]. |
enrolled_at | When this face first got a GUID. An identity minted 40 seconds ago is a different risk from one three years old. |
verifications | How many times this face has ever verified, anywhere. |
second_factor | Whether this identity is also protected by a passphrase. |
exp / jti | Five minutes by default, and the id the replay guard keys on. |
typ is faceguid-proof+jwt.
Check it. It is what stops an id_token from being presented where a proof belongs, and vice versa.Errors you should handle
| Code | Where | What to do |
|---|---|---|
window_closed | browser | The person changed their mind — or your page sets COOP: same-origin, which
severs the popup. Switch to redirect mode if it is the latter. |
popup_blocked | browser | You called proveHuman() outside a click. Move it into the handler or use redirect mode. |
origin_not_allowed | browser | This origin is not registered on the client. Add it in the console. |
domain_not_verified | registration | You tried to register a host you have not proved you control. Publish the token at
/.well-known/faceguid.txt on that host and verify it in the console.
localhost is exempt. |
token_already_used | server | A replay, or your own double-verify. 409 is the correct response to send on. |
expired | server | The proof sat around too long. Ask for a fresh one. |
nonce_mismatch | server | The proof answers a different challenge. Reject it. |
quota_exceeded | browser | Your organisation is over its monthly verifications. |
Sign-in, not just proof
When you want an account rather than an assertion, the same SDK does the full OpenID Connect flow — and any standards-compliant OIDC library works instead. The provider documentation →
// Full sign-in: the person ends up with a session in your app, no password.
const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
// 1. from a click
await fg.signIn({ scope: 'openid faceguid email', redirectUri: location.origin + '/' });
// 2. when they come back
const tokens = await fg.completeSignIn(); // null on a normal load
if (tokens) {
// tokens.claims.sub is your user id. Verify tokens.id_token server-side
// before you trust it — the browser copy is for rendering only.
await fetch('/api/session', { method: 'POST', body: JSON.stringify({ id_token: tokens.id_token }) });
}
Rules worth following
Never trust the token in the browser
A proof is a claim until your server verifies it with your secret. Client-side checks protect nobody — the attacker owns the client.
Always pass a nonce
Issue it from your server, bind it to the session, check it on the way back. Without one, a proof from any of your pages is valid on any other.
Keep the secret on the server
fgsk_… never belongs in a bundle, a mobile app, or a repository. Only its hash
is stored here, so a rotation is the only recovery.
Redirect mode if your page is COOP-isolated
A page served with Cross-Origin-Opener-Policy: same-origin loses its handle on
any popup it opens, so proveHuman() can never hear back. Use
mode: 'redirect' there — as this site’s own demo above does.
Treat liveness as strong, not absolute
The ceremony runs in the person's browser. The challenge is server-issued and single-use, which defeats replay — but for a high-value action, pair it with something else. The honest limits →