FaceGUID
Developers

Two questions, two endpoints.

“Is a live human here?” is a single POST — the proof-of-human API, for products that need a person rather than an account. “Who is this?” is plain OpenID Connect, so if your stack already speaks OIDC, pointing it here is a configuration change rather than an integration project.

Single-use signed proofs Authorization code + PKCE ES256 id_tokens Pairwise subjects Discovery document

Proof of human

The smallest useful question about a person, and the one most products actually need. You get a short-lived ES256 token from the browser and turn it into an answer with one authenticated POST. A proof is bound to your client, to your nonce and to a five-minute window, and it can be spent exactly once.

The whole thing
<script src="https://faceguid.com/sdk/faceguid.js"></script>
<script>
  const fg = new FaceGUID({ clientId: 'fg_YOUR_CLIENT_ID' });
  const proof = await fg.proveHuman({ nonce });      // opens the ceremony, returns a token
  await fetch('/api/human', { method: 'POST', body: JSON.stringify(proof) });
</script>

# your server, once:
curl -X POST https://faceguid.com/api/proof/verify \
  -u "$FACEGUID_CLIENT_ID:$FACEGUID_CLIENT_SECRET" \
  -H "content-type: application/json" \
  -d '{"token":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImZhY2VndWlkLXByb29mK2p3dCJ9...","nonce":"b7f2…"}'

# {"human": true, "sub": "6f1c2d0e-…", "verifications": 37, "enrolled_at": 1740873600, …}

Full reference, framework snippets and the server SDK: the SDK documentation →

EndpointAuthPurpose
/verifythe person The ceremony page the SDK opens. Returns the proof by postMessage or redirect.
/api/proof/contextnone What the ceremony page renders: client name, whether the calling origin is registered.
/api/proof/issueface session Mints the token. Only ever called by our own page, only after a passed ceremony.
/api/proof/verifyclient id + secret The authoritative check. Verifies and spends the proof. 409 on a replay.
/api/proof/redeemclient id + secret Spend a jti you verified offline against the JWKS.
The token is not a credential in the browser. It is a claim. It becomes an answer when your server verifies it with your secret — anything you check client-side is checked by code the attacker controls.

Seal certificates

When someone seals a document with their face and publishes the certificate, that certificate is a public, dependency-free artefact. Fetch it, hash the file you were given, check the signature. You never talk to us again, and it keeps verifying after we are gone.

Verify a seal from anywhere
const cert = await (await fetch(`https://faceguid.com/api/seal/${documentId}`)).json();

const b64 = (s) => Uint8Array.from(atob(s), c => c.charCodeAt(0));
const hash = btoa(String.fromCharCode(...new Uint8Array(
  await crypto.subtle.digest('SHA-256', fileBytes))));

const key = await crypto.subtle.importKey('spki', b64(cert.publicKey),
  { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
const signed = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key,
  b64(cert.signature), new TextEncoder().encode(cert.statement));

// All three, or it is not sealed.
const ok = signed
  && hash === cert.contentHash
  && cert.statement === ['faceguid/seal/v1', cert.faceguid, cert.documentId,
                          cert.contentHash, cert.sealedAt].join('|');

An unpublished certificate and a non-existent one both answer 404, deliberately: this endpoint is not an oracle for which documents exist. Human-readable verification lives at /seal?id=….

Sign-in — start here
Prove you control your domain.
In the console, publish the token it shows you at https://your-domain/.well-known/faceguid.txt and verify it. A client can only redirect to, or run the SDK on, a host you have proved — which is what stops this domain being used to bounce people somewhere else. localhost needs no proof, so you can build first and verify before you ship.
Register your app.
Sign in to the console with your face and add a redirect URI. You get a client_id and a secret shown exactly once.
Point your OIDC client at the discovery URL.
https://faceguid.com/.well-known/openid-configuration
Use sub as your user id.
Stable for that human, in your app, forever — even if they clear every cookie and switch devices. There is no password to reset.
Endpoints
EndpointMethodPurpose
/.well-known/openid-configurationGETDiscovery document.
/.well-known/jwks.jsonGETES256 public key for verifying id_tokens.
/authorizeGETConsent + liveness check. Redirects back with code.
/api/oauth/tokenPOSTExchange the code for an access token and id_token.
/api/oauth/userinfoGETClaims permitted by the granted scopes.
/api/oauth/introspectPOSTCheck an access token is still live.
/api/oauth/revokePOSTRevoke an access token.
/api/proof/verifyPOSTProof of human: verify and spend a proof token.
/sdk/faceguid.jsGETThe browser SDK. Served from this origin, CORS-open.
/api/seal/{document_id}GETA published seal certificate. Public, CORS-open, no credentials.
/api/domains/verifyPOSTProve control of a host so your clients may redirect to it.
Scopes
ScopeClaimsNotes
openidsub, amr, acr, auth_time Always granted. Proves a live human passed the check.
faceguidfaceguid The raw GUID. Only released when the app has pairwise subjects turned off.
profilename, faceguid_enrolled_at, faceguid_verifications Enrolment age is a useful signal against throwaway identities.
emailemail, email_verified Only ever a verified address.
phonephone_number, phone_number_verified Only ever a verified number.
linkslinks[] Verified websites and social profiles, each with proof of control.
Pairwise by default. Unless you turn it off, sub is HMAC(server key, client_id ‖ faceguid) — stable for you, useless to anyone else. Turn it off only if you genuinely need the cross-service identifier.
Node — openid-client
import { Issuer, generators } from 'openid-client';

const faceguid = await Issuer.discover('https://faceguid.com');
const client = new faceguid.Client({
  client_id: process.env.FACEGUID_CLIENT_ID,
  client_secret: process.env.FACEGUID_CLIENT_SECRET,
  redirect_uris: ['https://example.com/auth/callback'],
  response_types: ['code'],
});

// 1. send the user to FaceGUID
app.get('/auth/login', (req, res) => {
  const verifier = generators.codeVerifier();
  req.session.verifier = verifier;
  res.redirect(client.authorizationUrl({
    scope: 'openid faceguid email',
    code_challenge: generators.codeChallenge(verifier),
    code_challenge_method: 'S256',
    state: generators.state(),
  }));
});

// 2. they come back with a code
app.get('/auth/callback', async (req, res) => {
  const params = client.callbackParams(req);
  const tokens = await client.callback(
    'https://example.com/auth/callback', params,
    { code_verifier: req.session.verifier, state: req.session.state });

  const claims = tokens.claims();
  // claims.sub  -> stable identifier for this human, in your app
  // claims.amr  -> ['face', 'liveness']
  req.session.user = claims.sub;
  res.redirect('/');
});
curl
# exchange the code
curl -X POST https://faceguid.com/api/oauth/token \
  -u "$FACEGUID_CLIENT_ID:$FACEGUID_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=THE_CODE \
  -d redirect_uri=https://example.com/auth/callback

# {
#   "access_token": "...",
#   "token_type": "Bearer",
#   "expires_in": 3600,
#   "scope": "openid faceguid email",
#   "id_token": "eyJhbGciOiJFUzI1NiIs..."
# }

# read the profile
curl https://faceguid.com/api/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"
Verifying an id_token yourself
// A Cloudflare Worker verifying a FaceGUID id_token, no dependencies.
const JWKS = 'https://faceguid.com/.well-known/jwks.json';

async function verifyIdToken(jwt, clientId) {
  const [h, p, s] = jwt.split('.');
  const header  = JSON.parse(atob(h.replace(/-/g,'+').replace(/_/g,'/')));
  const payload = JSON.parse(atob(p.replace(/-/g,'+').replace(/_/g,'/')));

  const { keys } = await (await fetch(JWKS)).json();
  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 sig = Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
  const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig,
    new TextEncoder().encode(`${h}.${p}`));

  if (!ok) throw new Error('bad signature');
  if (payload.iss !== 'https://faceguid.com') throw new Error('bad issuer');
  if (payload.aud !== clientId) throw new Error('bad audience');
  if (payload.exp * 1000 < Date.now()) throw new Error('expired');
  return payload;   // payload.sub is your user
}
What the claims mean
amr: ["face","liveness"] Authentication was a face match plus a speak-the-digits liveness challenge. There is no password anywhere in this flow.
acr: "faceguid:liveness:speak-digits" Names the exact ceremony used, so you can require it and reject weaker ones if the set ever grows.
auth_time When the face check actually happened. Codes expire in 120 seconds, so this is always recent.
Honest limits

Worth knowing before you make FaceGUID your only factor:

  • Liveness analysis runs in the user’s browser. The challenge digits are server-issued and single-use, which stops replay, but a determined attacker controlling the client can report whatever evidence they like. Treat amr as a strong signal, not a proof.
  • Face recognition has a false-match rate. The threshold here is tuned tight (0.45 on L2-normalised 128-D descriptors), but identical twins are a known limitation of every face biometric, this one included.
  • For high-value actions, pair FaceGUID with something the user knows or holds. The vault supports exactly that with an optional passphrase.