FaceGUID
SDK

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.

No dependencies No build step No camera permission on your domain 5 KB gzipped
1 · In the browser
<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>
2 · On your server
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...
  }
});
Try it here live

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

CallWhat 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.
The drop-in button
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),
});
Redirect mode
// 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

CallWhat 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.
Verifying offline
// 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

ClaimMeaning
humanAlways true in a proof. A token without it is not one.
subThe person, as your client sees them. Stable forever; different at every other client.
faceguidThe real global GUID. Only present when your client has pairwise subjects turned off.
nonceWhatever you passed in. Check it, and a proof cannot be lifted from another session.
acr / amrThe ceremony that was passed: faceguid:liveness:speak-digits, ["face","liveness"].
enrolled_atWhen this face first got a GUID. An identity minted 40 seconds ago is a different risk from one three years old.
verificationsHow many times this face has ever verified, anywhere.
second_factorWhether this identity is also protected by a passphrase.
exp / jtiFive minutes by default, and the id the replay guard keys on.
Header 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

CodeWhereWhat to do
window_closedbrowser 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_blockedbrowserYou called proveHuman() outside a click. Move it into the handler or use redirect mode.
origin_not_allowedbrowserThis origin is not registered on the client. Add it in the console.
domain_not_verifiedregistration 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_usedserverA replay, or your own double-verify. 409 is the correct response to send on.
expiredserverThe proof sat around too long. Ask for a fresh one.
nonce_mismatchserverThe proof answers a different challenge. Reject it.
quota_exceededbrowserYour 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 →

Face sign-in, browser side
// 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 →